From 55e78e088a22036b3088965cd168ab83b12b9a4f Mon Sep 17 00:00:00 2001 From: defnotken Date: Fri, 24 Oct 2025 09:45:24 -0500 Subject: [PATCH 001/140] Initialize .4 --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index b4b5288..726f2ef 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.3 + 1.12.4 https://github.com/Light-Public-Syncshells/LightlessClient -- 2.49.1 From 437731749fc817d0abd80d05a8e336d5fb3dd0ea Mon Sep 17 00:00:00 2001 From: choco Date: Sun, 26 Oct 2025 17:34:17 +0100 Subject: [PATCH 002/140] pair button now has additional checks to show if the user isnt directly paired, and only shows if your own lightfinder is on --- LightlessSync/Services/ContextMenuService.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 97bfc17..42ab72a 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -98,7 +98,7 @@ internal class ContextMenuService : IHostedService if (targetData == null || targetData.Address == nint.Zero) return; - //Check if user is paired or is own. + //Check if user is directly paired or is own. if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId) return; @@ -113,10 +113,15 @@ internal class ContextMenuService : IHostedService if (!_configService.Current.EnableRightClickMenus) return; + + //Check if lightfinder is on. + if (!_configService.Current.BroadcastEnabled) + return; + args.AddMenuItem(new MenuItem { - Name = "Send Pair Request", + Name = "Send Direct Pair Request", PrefixChar = 'L', UseDefaultPrefix = false, PrefixColor = 708, @@ -159,7 +164,7 @@ internal class ContextMenuService : IHostedService } } - private HashSet VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() + private HashSet VisibleUserIds => [.. _pairManager.DirectPairs .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; -- 2.49.1 From ce5f8a43a2048d4a83fbf4619eed8801f022a613 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 27 Oct 2025 16:16:40 +0100 Subject: [PATCH 003/140] Added list of users names in the dtr entry whenever lightfinder is active --- .../Services/BroadcastScanningService.cs | 12 +++- LightlessSync/Services/PairRequestService.cs | 10 +-- LightlessSync/UI/DtrEntry.cs | 68 +++++++++++++------ 3 files changed, 60 insertions(+), 30 deletions(-) diff --git a/LightlessSync/Services/BroadcastScanningService.cs b/LightlessSync/Services/BroadcastScanningService.cs index 79fe984..95abdae 100644 --- a/LightlessSync/Services/BroadcastScanningService.cs +++ b/LightlessSync/Services/BroadcastScanningService.cs @@ -28,7 +28,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos private readonly CancellationTokenSource _cleanupCts = new(); private Task? _cleanupTask; - private int _checkEveryFrames = 20; + private readonly int _checkEveryFrames = 20; private int _frameCounter = 0; private int _lookupsThisFrame = 0; private const int MaxLookupsPerFrame = 30; @@ -221,6 +221,16 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid))); } + public List> GetActiveBroadcasts(string? excludeHashedCid = null) + { + var now = DateTime.UtcNow; + var comparer = StringComparer.Ordinal; + return [.. _broadcastCache.Where(entry => + entry.Value.IsBroadcasting && + entry.Value.ExpiryTime > now && + (excludeHashedCid is null || !comparer.Equals(entry.Key, excludeHashedCid)))]; + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); diff --git a/LightlessSync/Services/PairRequestService.cs b/LightlessSync/Services/PairRequestService.cs index 7190825..2531a3a 100644 --- a/LightlessSync/Services/PairRequestService.cs +++ b/LightlessSync/Services/PairRequestService.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; @@ -14,10 +10,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase private readonly DalamudUtilService _dalamudUtil; private readonly PairManager _pairManager; private readonly Lazy _apiController; - private readonly object _syncRoot = new(); + private readonly Lock _syncRoot = new(); private readonly List _requests = []; - private static readonly TimeSpan Expiration = TimeSpan.FromMinutes(5); + private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5); public PairRequestService( ILogger logger, @@ -189,7 +185,7 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase } var now = DateTime.UtcNow; - return _requests.RemoveAll(r => now - r.ReceivedAt > Expiration) > 0; + return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0; } public void AcceptPairRequest(string hashedCid, string displayName) diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 89c7389..17bc871 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -2,19 +2,22 @@ using Dalamud.Game.Gui.Dtr; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Services; +using Dalamud.Utility; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; -using LightlessSync.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using System.Runtime.InteropServices; using System.Text; +using static LightlessSync.Services.PairRequestService; namespace LightlessSync.UI; @@ -106,7 +109,7 @@ public sealed class DtrEntry : IDisposable, IHostedService } catch (OperationCanceledException) { - + _logger.LogInformation("Lightfinder operation was canceled."); } finally { @@ -363,29 +366,46 @@ public sealed class DtrEntry : IDisposable, IHostedService } } - private int GetNearbyBroadcastCount() - { - var localHashedCid = GetLocalHashedCid(); - return _broadcastScannerService.CountActiveBroadcasts( - string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid); - } - - private int GetPendingPairRequestCount() + private List GetNearbyBroadcasts() { try { - return _pairRequestService.GetActiveRequests().Count; + var localHashedCid = GetLocalHashedCid(); + return [.. _broadcastScannerService + .GetActiveBroadcasts(string.IsNullOrEmpty(localHashedCid) ? null : localHashedCid) + .Select(b => _dalamudUtilService.FindPlayerByNameHash(b.Key).Name)]; } catch (Exception ex) { var now = DateTime.UtcNow; + + if (now >= _pairRequestNextErrorLog) + { + _logger.LogDebug(ex, "Failed to retrieve nearby broadcasts for Lightfinder DTR entry."); + _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; + } + + return []; + } + } + + private IReadOnlyList GetPendingPairRequest() + { + try + { + return _pairRequestService.GetActiveRequests(); + } + catch (Exception ex) + { + var now = DateTime.UtcNow; + if (now >= _pairRequestNextErrorLog) { _logger.LogDebug(ex, "Failed to retrieve pair request count for Lightfinder DTR entry."); _pairRequestNextErrorLog = now + _localHashedCidErrorCooldown; } - return 0; + return []; } } @@ -400,23 +420,15 @@ public sealed class DtrEntry : IDisposable, IHostedService if (_broadcastService.IsBroadcasting) { - var tooltipBuilder = new StringBuilder("Lightfinder - Enabled"); - switch (config.LightfinderDtrDisplayMode) { case LightfinderDtrDisplayMode.PendingPairRequests: { - var requestCount = GetPendingPairRequestCount(); - tooltipBuilder.AppendLine(); - tooltipBuilder.Append("Pending pair requests: ").Append(requestCount); - return ($"{icon} Requests {requestCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString()); + return FormatTooltip("Pending pair requests", GetPendingPairRequest().Select(x => x.DisplayName), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled)); } default: { - var broadcastCount = GetNearbyBroadcastCount(); - tooltipBuilder.AppendLine(); - tooltipBuilder.Append("Nearby Lightfinder users: ").Append(broadcastCount); - return ($"{icon} {broadcastCount}", SwapColorChannels(config.DtrColorsLightfinderEnabled), tooltipBuilder.ToString()); + return FormatTooltip("Nearby Lightfinder users", GetNearbyBroadcasts(), icon, SwapColorChannels(config.DtrColorsLightfinderEnabled)); } } } @@ -433,6 +445,18 @@ public sealed class DtrEntry : IDisposable, IHostedService return ($"{icon} OFF", colors, tooltip.ToString()); } + private (string, Colors, string) FormatTooltip(string title, IEnumerable names, string icon, Colors color) + { + var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList(); + var tooltip = new StringBuilder() + .Append($"Lightfinder - Enabled{Environment.NewLine}") + .Append($"{title}: {list.Count}{Environment.NewLine}") + .AppendJoin(Environment.NewLine, list) + .ToString(); + + return ($"{icon} {list.Count}", color, tooltip); + } + private static string BuildLightfinderTooltip(string baseTooltip) { var builder = new StringBuilder(); -- 2.49.1 From 8bccdc5ef1bdcfb9236d2ab2b1438d60eb6cd56e Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 27 Oct 2025 22:26:03 +0100 Subject: [PATCH 004/140] Added lightless command. --- LightlessSync/Services/CommandManagerService.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index 7aedc7b..88f8780 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -13,7 +13,8 @@ namespace LightlessSync.Services; public sealed class CommandManagerService : IDisposable { - private const string _commandName = "/light"; + private const string _longName = "/lightless"; + private const string _shortName = "/light"; private readonly ApiController _apiController; private readonly ICommandManager _commandManager; @@ -34,7 +35,11 @@ public sealed class CommandManagerService : IDisposable _apiController = apiController; _mediator = mediator; _lightlessConfigService = lightlessConfigService; - _commandManager.AddHandler(_commandName, new CommandInfo(OnCommand) + _commandManager.AddHandler(_longName, new CommandInfo(OnCommand) + { + HelpMessage = $"\u2191;" + }); + _commandManager.AddHandler(_shortName, new CommandInfo(OnCommand) { HelpMessage = "Opens the Lightless Sync UI" + Environment.NewLine + Environment.NewLine + "Additionally possible commands:" + Environment.NewLine + @@ -49,7 +54,8 @@ public sealed class CommandManagerService : IDisposable public void Dispose() { - _commandManager.RemoveHandler(_commandName); + _commandManager.RemoveHandler(_longName); + _commandManager.RemoveHandler(_shortName); } private void OnCommand(string command, string args) -- 2.49.1 From cabc4ec0fe2df6082c8a020421960500d33794c9 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 28 Oct 2025 00:57:49 +0100 Subject: [PATCH 005/140] removed lightfinder on check --- LightlessSync/Services/ContextMenuService.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 42ab72a..464fee1 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -113,11 +113,6 @@ internal class ContextMenuService : IHostedService if (!_configService.Current.EnableRightClickMenus) return; - - //Check if lightfinder is on. - if (!_configService.Current.BroadcastEnabled) - return; - args.AddMenuItem(new MenuItem { -- 2.49.1 From c16891021c456159b586d94eda4d52b46e8fd4ab Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 28 Oct 2025 18:20:57 +0100 Subject: [PATCH 006/140] Added pause button for all syncshells and grouped syncshells. --- LightlessSync/UI/CompactUI.cs | 17 ++--- .../UI/Components/DrawGroupedGroupFolder.cs | 62 ++++++++++++++++--- LightlessSync/UI/Components/IDrawFolder.cs | 3 +- LightlessSync/UI/Models/GroupFolder.cs | 6 ++ 4 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 LightlessSync/UI/Models/GroupFolder.cs diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 0700de3..cc8d326 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -1,4 +1,3 @@ -using System; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; @@ -16,12 +15,14 @@ using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Components; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; +using System; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Globalization; @@ -708,23 +709,23 @@ public class CompactUi : WindowMediatorSubscriberBase } //Filter of not foldered syncshells - var groupFolders = new List(); + var groupFolders = new List(); foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) { GetGroups(allPairs, filteredPairs, group, out ImmutableList allGroupPairs, out Dictionary> filteredGroupPairs); if (FilterNotTaggedSyncshells(group)) { - groupFolders.Add(_drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs)); + groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs))); } } //Filter of grouped up syncshells (All Syncshells Folder) if (_configService.Current.GroupUpSyncshells) - drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _uiSharedService, + drawFolders.Add(new DrawGroupedGroupFolder(groupFolders, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, "")); else - drawFolders.AddRange(groupFolders); + drawFolders.AddRange(groupFolders.Select(v => v.GroupDrawFolder)); //Filter of grouped/foldered pairs foreach (var tag in _tagHandler.GetAllPairTagsSorted()) @@ -738,7 +739,7 @@ public class CompactUi : WindowMediatorSubscriberBase //Filter of grouped/foldered syncshells foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted()) { - var syncshellFolderTags = new List(); + var syncshellFolderTags = new List(); foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) { if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag)) @@ -747,11 +748,11 @@ public class CompactUi : WindowMediatorSubscriberBase out ImmutableList allGroupPairs, out Dictionary> filteredGroupPairs); - syncshellFolderTags.Add(_drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs)); + syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs))); } } - drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag)); + drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag)); } //Filter of not grouped/foldered and offline pairs diff --git a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs index 2aa3d5c..59da7ef 100644 --- a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs +++ b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs @@ -1,7 +1,11 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.Group; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; +using LightlessSync.WebAPI; using System.Collections.Immutable; using System.Numerics; @@ -10,19 +14,20 @@ namespace LightlessSync.UI.Components; public class DrawGroupedGroupFolder : IDrawFolder { private readonly string _tag; - private readonly IEnumerable _groups; + private readonly IEnumerable _groups; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; + private readonly ApiController _apiController; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private bool _wasHovered = false; private float _menuWidth; - public IImmutableList DrawPairs => throw new NotSupportedException(); - public int OnlinePairs => _groups.SelectMany(g => g.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); - public int TotalPairs => _groups.Sum(g => g.TotalPairs); + public IImmutableList DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); + public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); + public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs); - public DrawGroupedGroupFolder(IEnumerable groups, TagHandler tagHandler, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag) + public DrawGroupedGroupFolder(IEnumerable groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag) { _groups = groups; _tagHandler = tagHandler; @@ -30,6 +35,7 @@ public class DrawGroupedGroupFolder : IDrawFolder _selectSyncshellForTagUi = selectSyncshellForTagUi; _renameSyncshellTagUi = renameSyncshellTagUi; _tag = tag; + _apiController = apiController; } public void Draw() @@ -42,7 +48,7 @@ public class DrawGroupedGroupFolder : IDrawFolder using var id = ImRaii.PushId(_id); var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); - using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) + using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) { ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight())); using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f))) @@ -83,11 +89,16 @@ public class DrawGroupedGroupFolder : IDrawFolder { ImGui.TextUnformatted(_tag); + ImGui.SameLine(); + DrawPauseButton(); ImGui.SameLine(); DrawMenu(); } else { ImGui.TextUnformatted("All Syncshells"); + + ImGui.SameLine(); + DrawPauseButton(); } } color.Dispose(); @@ -100,10 +111,47 @@ public class DrawGroupedGroupFolder : IDrawFolder using var indent = ImRaii.PushIndent(20f); foreach (var entry in _groups) { - entry.Draw(); + entry.GroupDrawFolder.Draw(); } } } + protected void DrawPauseButton() + { + if (DrawPairs.Count > 0) + { + var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused()); + FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; + + var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon); + var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth(); + if (_tag != "") + { + var spacingX = ImGui.GetStyle().ItemSpacing.X; + var menuButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV); + ImGui.SameLine(windowEndX - pauseButtonSize.X - menuButtonSize.X - spacingX); + } + else + { + ImGui.SameLine(windowEndX - pauseButtonSize.X); + } + + + if (_uiSharedService.IconButton(pauseIcon)) + { + ChangePauseStateGroups(); + } + } + } + + protected void ChangePauseStateGroups() + { + foreach(var group in _groups) + { + var perm = group.GroupFullInfo.GroupUserPermissions; + perm.SetPaused(!perm.IsPaused()); + _ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(group.GroupFullInfo.Group, new(_apiController.UID), perm)); + } + } protected void DrawMenu() { diff --git a/LightlessSync/UI/Components/IDrawFolder.cs b/LightlessSync/UI/Components/IDrawFolder.cs index eda1fce..faf8a69 100644 --- a/LightlessSync/UI/Components/IDrawFolder.cs +++ b/LightlessSync/UI/Components/IDrawFolder.cs @@ -1,5 +1,4 @@ - -using System.Collections.Immutable; +using System.Collections.Immutable; namespace LightlessSync.UI.Components; diff --git a/LightlessSync/UI/Models/GroupFolder.cs b/LightlessSync/UI/Models/GroupFolder.cs new file mode 100644 index 0000000..cedeef0 --- /dev/null +++ b/LightlessSync/UI/Models/GroupFolder.cs @@ -0,0 +1,6 @@ +using LightlessSync.API.Dto.Group; +using LightlessSync.UI.Components; + +namespace LightlessSync.UI.Models; + +public record GroupFolder(GroupFullInfoDto GroupFullInfo, IDrawFolder GroupDrawFolder); -- 2.49.1 From de75b90703b0bb73118cbd5c09c8aaa1202ab330 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 28 Oct 2025 18:23:01 +0100 Subject: [PATCH 007/140] Added spacing on function --- LightlessSync/UI/Components/DrawGroupedGroupFolder.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs index 59da7ef..1bb3d79 100644 --- a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs +++ b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs @@ -115,6 +115,7 @@ public class DrawGroupedGroupFolder : IDrawFolder } } } + protected void DrawPauseButton() { if (DrawPairs.Count > 0) -- 2.49.1 From 177534d78b32269c8c96f65d5c01043cf5cdca1b Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 04:37:24 +0100 Subject: [PATCH 008/140] Implemented compactor to work on BTRFS, redid cache a bit for better function on linux. Removed error for websockets, it will be forced on wine again. --- LightlessSync/FileCache/CacheMonitor.cs | 17 +- LightlessSync/FileCache/FileCacheManager.cs | 80 ++-- LightlessSync/FileCache/FileCompactor.cs | 374 +++++++++++++++--- .../Models/ServerStorage.cs | 1 - LightlessSync/UI/SettingsUi.cs | 36 +- LightlessSync/Utils/Crypto.cs | 20 + LightlessSync/Utils/FileSystemHelper.cs | 143 +++++++ LightlessSync/WebAPI/SignalR/HubFactory.cs | 7 - 8 files changed, 572 insertions(+), 106 deletions(-) create mode 100644 LightlessSync/Utils/FileSystemHelper.cs diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 3b41d85..23f5c19 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -115,6 +115,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase public bool StorageisNTFS { get; private set; } = false; + public bool StorageIsBtrfs { get ; private set; } = false; + public void StartLightlessWatcher(string? lightlessPath) { LightlessWatcher?.Dispose(); @@ -124,10 +126,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless."); return; } + var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder); - DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); - StorageisNTFS = string.Equals("NTFS", di.DriveFormat, StringComparison.OrdinalIgnoreCase); - Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS); + if (fsType == FileSystemHelper.FilesystemType.NTFS) + { + StorageisNTFS = true; + Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS); + } + + if (fsType == FileSystemHelper.FilesystemType.Btrfs) + { + StorageIsBtrfs = true; + Logger.LogInformation("Lightless Storage is on BTRFS drive: {isNtfs}", StorageIsBtrfs); + } Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath); LightlessWatcher = new() diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index 972c4d9..7ee6c99 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -203,42 +203,72 @@ public sealed class FileCacheManager : IHostedService return output; } - public Task> ValidateLocalIntegrity(IProgress<(int, int, FileCacheEntity)> progress, CancellationToken cancellationToken) + public async Task> ValidateLocalIntegrity(IProgress<(int completed, int total, FileCacheEntity current)> progress, CancellationToken cancellationToken) { _lightlessMediator.Publish(new HaltScanMessage(nameof(ValidateLocalIntegrity))); _logger.LogInformation("Validating local storage"); - var cacheEntries = _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).Where(v => v.IsCacheEntry).ToList(); - List brokenEntities = []; - int i = 0; - foreach (var fileCache in cacheEntries) + + var cacheEntries = _fileCaches.Values + .SelectMany(v => v.Values) + .Where(v => v.IsCacheEntry) + .ToList(); + + int total = cacheEntries.Count; + int processed = 0; + var brokenEntities = new ConcurrentBag(); + + _logger.LogInformation("Checking {count} cache entries...", total); + + await Parallel.ForEachAsync(cacheEntries, new ParallelOptions + { + MaxDegreeOfParallelism = Environment.ProcessorCount, + CancellationToken = cancellationToken + }, + async (fileCache, token) => { - if (cancellationToken.IsCancellationRequested) break; - - _logger.LogInformation("Validating {file}", fileCache.ResolvedFilepath); - - progress.Report((i, cacheEntries.Count, fileCache)); - i++; - if (!File.Exists(fileCache.ResolvedFilepath)) - { - brokenEntities.Add(fileCache); - continue; - } - try { - var computedHash = Crypto.GetFileHash(fileCache.ResolvedFilepath); + int current = Interlocked.Increment(ref processed); + if (current % 10 == 0) + progress.Report((current, total, fileCache)); + + if (!File.Exists(fileCache.ResolvedFilepath)) + { + brokenEntities.Add(fileCache); + return; + } + + string computedHash; + try + { + computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error hashing {file}", fileCache.ResolvedFilepath); + brokenEntities.Add(fileCache); + return; + } + if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal)) { - _logger.LogInformation("Failed to validate {file}, got hash {computedHash}, expected hash {hash}", fileCache.ResolvedFilepath, computedHash, fileCache.Hash); + _logger.LogInformation( + "Hash mismatch: {file} (got {computedHash}, expected {expected})", + fileCache.ResolvedFilepath, computedHash, fileCache.Hash); + brokenEntities.Add(fileCache); } } - catch (Exception e) + catch (OperationCanceledException) { - _logger.LogWarning(e, "Error during validation of {file}", fileCache.ResolvedFilepath); + _logger.LogError("Validation got cancelled for {file}", fileCache.ResolvedFilepath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error validating {file}", fileCache.ResolvedFilepath); brokenEntities.Add(fileCache); } - } + }).ConfigureAwait(false); foreach (var brokenEntity in brokenEntities) { @@ -250,12 +280,14 @@ public sealed class FileCacheManager : IHostedService } catch (Exception ex) { - _logger.LogWarning(ex, "Could not delete {file}", brokenEntity.ResolvedFilepath); + _logger.LogWarning(ex, "Failed to delete invalid cache file {file}", brokenEntity.ResolvedFilepath); } } _lightlessMediator.Publish(new ResumeScanMessage(nameof(ValidateLocalIntegrity))); - return Task.FromResult(brokenEntities); + _logger.LogInformation("Validation complete. Found {count} invalid entries.", brokenEntities.Count); + + return [.. brokenEntities]; } public string GetCacheFilePath(string hash, string extension) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 1a35ad6..e5d219e 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,11 +1,15 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; -using System.Runtime.InteropServices; using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; +using static LightlessSync.Utils.FileSystemHelper; namespace LightlessSync.FileCache; @@ -87,25 +91,51 @@ public sealed class FileCompactor : IDisposable public long GetFileSizeOnDisk(FileInfo fileInfo, bool? isNTFS = null) { - bool ntfs = isNTFS ?? string.Equals(new DriveInfo(fileInfo.Directory!.Root.FullName).DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase); + var fsType = FileSystemHelper.GetFilesystemType(fileInfo.FullName); - if (_dalamudUtilService.IsWine || !ntfs) return fileInfo.Length; + bool ntfs = isNTFS ?? fsType == FileSystemHelper.FilesystemType.NTFS; - var clusterSize = GetClusterSize(fileInfo); - if (clusterSize == -1) return fileInfo.Length; - var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); - var size = (long)hosize << 32 | losize; - return ((size + clusterSize - 1) / clusterSize) * clusterSize; + if (fsType != FileSystemHelper.FilesystemType.Btrfs && !ntfs) + { + return fileInfo.Length; + } + + if (ntfs && !_dalamudUtilService.IsWine) + { + var clusterSize = GetClusterSize(fileInfo); + if (clusterSize == -1) return fileInfo.Length; + var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); + var size = (long)hosize << 32 | losize; + return ((size + clusterSize - 1) / clusterSize) * clusterSize; + } + + if (fsType == FileSystemHelper.FilesystemType.Btrfs) + { + try + { + long blocks = RunStatGetBlocks(fileInfo.FullName); + return blocks * 512L; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get on-disk size via stat for {file}, falling back to Length", fileInfo.FullName); + return fileInfo.Length; + } + } + + return fileInfo.Length; } public async Task WriteAllBytesAsync(string filePath, byte[] decompressedFile, CancellationToken token) { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + await File.WriteAllBytesAsync(filePath, decompressedFile, token).ConfigureAwait(false); - if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor) - { + if (!_lightlessConfigService.Current.UseCompactor) return; - } EnqueueCompaction(filePath); } @@ -153,56 +183,178 @@ public sealed class FileCompactor : IDisposable private void CompactFile(string filePath) { - var fs = new DriveInfo(new FileInfo(filePath).Directory!.Root.FullName); - bool isNTFS = string.Equals(fs.DriveFormat, "NTFS", StringComparison.OrdinalIgnoreCase); - if (!isNTFS) + var fi = new FileInfo(filePath); + if (!fi.Exists) { - _logger.LogWarning("Drive for file {file} is not NTFS", filePath); + _logger.LogDebug("Skipping compaction for missing file {file}", filePath); return; } - var fi = new FileInfo(filePath); + var fsType = FileSystemHelper.GetFilesystemType(filePath); var oldSize = fi.Length; - var clusterSize = GetClusterSize(fi); + int clusterSize = GetClusterSize(fi); if (oldSize < Math.Max(clusterSize, 8 * 1024)) { _logger.LogDebug("File {file} is smaller than cluster size ({size}), ignoring", filePath, clusterSize); return; } - if (!IsCompactedFile(filePath)) + // NTFS Compression. + if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtilService.IsWine) { - _logger.LogDebug("Compacting file to XPRESS8K: {file}", filePath); + if (!IsWOFCompactedFile(filePath)) + { + _logger.LogDebug("Compacting file to XPRESS8K: {file}", filePath); + var success = WOFCompressFile(filePath); - WOFCompressFile(filePath); + if (success) + { + var newSize = GetFileSizeOnDisk(fi); + _logger.LogDebug("Compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize); + } + else + { + _logger.LogWarning("NTFS compression failed or not available for {file}", filePath); + } - var newSize = GetFileSizeOnDisk(fi); - - _logger.LogDebug("Compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize); + } + else + { + _logger.LogDebug("File {file} already compressed (NTFS)", filePath); + } } - else + + // BTRFS Compression + if (fsType == FileSystemHelper.FilesystemType.Btrfs) { - _logger.LogDebug("File {file} already compressed", filePath); + if (!IsBtrfsCompressedFile(filePath)) + { + _logger.LogDebug("Attempting btrfs compression for {file}", filePath); + var success = BtrfsCompressFile(filePath); + + if (success) + { + var newSize = GetFileSizeOnDisk(fi); + _logger.LogDebug("Btrfs-compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize); + } + else + { + _logger.LogWarning("Btrfs compression failed or not available for {file}", filePath); + } + } + else + { + _logger.LogDebug("File {file} already compressed (Btrfs)", filePath); + } } } + private static long RunStatGetBlocks(string path) + { + var psi = new ProcessStartInfo("stat", $"-c %b \"{path}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start stat process"); + var outp = proc.StandardOutput.ReadToEnd(); + var err = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode != 0) + { + throw new InvalidOperationException($"stat failed: {err}"); + } + + if (!long.TryParse(outp.Trim(), out var blocks)) + { + throw new InvalidOperationException($"invalid stat output: {outp}"); + } + + return blocks; + } + private void DecompressFile(string path) { _logger.LogDebug("Removing compression from {file}", path); - try + var fsType = FileSystemHelper.GetFilesystemType(path); + if (fsType == null) return; + + //NTFS Decompression + if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtilService.IsWine) { - using (var fs = new FileStream(path, FileMode.Open)) + try { + using (var fs = new FileStream(path, FileMode.Open)) + { #pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called - var hDevice = fs.SafeFileHandle.DangerousGetHandle(); + var hDevice = fs.SafeFileHandle.DangerousGetHandle(); #pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called - _ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _); + _ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _); + } } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error decompressing file {path}", path); + } + return; } - catch (Exception ex) + + //BTRFS Decompression + if (fsType == FileSystemHelper.FilesystemType.Btrfs) { - _logger.LogWarning(ex, "Error decompressing file {path}", path); + try + { + var mountOptions = GetMountOptionsForPath(path); + if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). " + + "Remount with 'compress=no' before running decompression.", path, mountOptions); + return; + } + + _logger.LogDebug("Rewriting {file} to remove btrfs compression...", path); + + var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -- \"{path}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi); + if (proc == null) + { + _logger.LogWarning("Failed to start btrfs defragment for decompression of {file}", path); + return; + } + + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + _logger.LogWarning("btrfs defragment failed for {file}: {err}", path, stderr); + } + else + { + // Log output only in debug mode to avoid clutter + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogDebug("btrfs defragment output for {file}: {out}", path, stdout.Trim()); + + _logger.LogInformation("Decompressed (rewritten uncompressed) btrfs file: {file}", path); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error rewriting {file} for decompression", path); + } } } @@ -220,7 +372,7 @@ public sealed class FileCompactor : IDisposable return _clusterSizes[root]; } - private static bool IsCompactedFile(string filePath) + private static bool IsWOFCompactedFile(string filePath) { uint buf = 8; _ = WofIsExternalFile(filePath, out int isExtFile, out uint _, out var info, ref buf); @@ -228,40 +380,151 @@ public sealed class FileCompactor : IDisposable return info.Algorithm == CompressionAlgorithm.XPRESS8K; } - private void WOFCompressFile(string path) + private bool IsBtrfsCompressedFile(string path) + { + try + { + var psi = new ProcessStartInfo("filefrag", $"-v \"{path}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi); + if (proc == null) + { + _logger.LogWarning("Failed to start filefrag for {file}", path); + return false; + } + + string output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + + // look for "flags: compressed" in the output + if (output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to detect btrfs compression for {file}", path); + return false; + } + } + + private bool WOFCompressFile(string path) { var efInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf(_efInfo)); Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: true); ulong length = (ulong)Marshal.SizeOf(_efInfo); try { - using (var fs = new FileStream(path, FileMode.Open)) + using var fs = new FileStream(path, FileMode.Open); + #pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called + var hFile = fs.SafeFileHandle.DangerousGetHandle(); + #pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called + if (fs.SafeFileHandle.IsInvalid) { -#pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called - var hFile = fs.SafeFileHandle.DangerousGetHandle(); -#pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called - if (fs.SafeFileHandle.IsInvalid) - { - _logger.LogWarning("Invalid file handle to {file}", path); - } - else - { - var ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length); - if (!(ret == 0 || ret == unchecked((int)0x80070158))) - { - _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); - } - } + _logger.LogWarning("Invalid file handle to {file}", path); + return false; + } + + var ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length); + if (!(ret == 0 || ret == unchecked((int)0x80070158))) + { + _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); + return false; } } catch (Exception ex) { _logger.LogWarning(ex, "Error compacting file {path}", path); + return false; } finally { Marshal.FreeHGlobal(efInfoPtr); } + return true; + } + + private bool BtrfsCompressFile(string path) + { + try + { + var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -czstd -- \"{path}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi); + if (proc == null) + { + _logger.LogWarning("Failed to start btrfs process for {file}", path); + return false; + } + + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + _logger.LogWarning("btrfs defrag returned {code} for {file}: {err}", proc.ExitCode, path, stderr); + return false; + } + + _logger.LogDebug("btrfs output: {out}", stdout); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error running btrfs defragment for {file}", path); + return false; + } + } + + private string GetMountOptionsForPath(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var mounts = File.ReadAllLines("/proc/mounts"); + string bestMount = string.Empty; + string mountOptions = string.Empty; + + foreach (var line in mounts) + { + var parts = line.Split(' '); + if (parts.Length < 4) continue; + var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); // unescape spaces + string normalized; + try { normalized = Path.GetFullPath(mountPoint); } + catch { normalized = mountPoint; } + + if (fullPath.StartsWith(normalized, StringComparison.Ordinal) && + normalized.Length > bestMount.Length) + { + bestMount = normalized; + mountOptions = parts[3]; + } + } + + return mountOptions; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to get mount options for {path}", path); + return string.Empty; + } } private struct WOF_FILE_COMPRESSION_INFO_V1 @@ -273,7 +536,14 @@ public sealed class FileCompactor : IDisposable private void EnqueueCompaction(string filePath) { if (!_pendingCompactions.TryAdd(filePath, 0)) + return; + + var fsType = GetFilesystemType(filePath); + + if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs) { + _logger.LogTrace("Skipping compaction enqueue for unsupported filesystem {fs} ({file})", fsType, filePath); + _pendingCompactions.TryRemove(filePath, out _); return; } @@ -282,6 +552,10 @@ public sealed class FileCompactor : IDisposable _pendingCompactions.TryRemove(filePath, out _); _logger.LogDebug("Failed to enqueue compaction job for {file}", filePath); } + else + { + _logger.LogTrace("Queued compaction job for {file} (fs={fs})", filePath, fsType); + } } private async Task ProcessQueueAsync(CancellationToken token) @@ -299,7 +573,7 @@ public sealed class FileCompactor : IDisposable return; } - if (_dalamudUtilService.IsWine || !_lightlessConfigService.Current.UseCompactor) + if (!_lightlessConfigService.Current.UseCompactor) { continue; } diff --git a/LightlessSync/LightlessConfiguration/Models/ServerStorage.cs b/LightlessSync/LightlessConfiguration/Models/ServerStorage.cs index 2b003bb..20302fe 100644 --- a/LightlessSync/LightlessConfiguration/Models/ServerStorage.cs +++ b/LightlessSync/LightlessConfiguration/Models/ServerStorage.cs @@ -13,5 +13,4 @@ public class ServerStorage public bool UseOAuth2 { get; set; } = false; public string? OAuthToken { get; set; } = null; public HttpTransportType HttpTransportType { get; set; } = HttpTransportType.WebSockets; - public bool ForceWebSockets { get; set; } = false; } \ No newline at end of file diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index febc142..d686a75 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1227,16 +1227,16 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TextUnformatted($"Currently utilized local storage: Calculating..."); ImGui.TextUnformatted( $"Remaining space free on drive: {UiSharedService.ByteToString(_cacheMonitor.FileCacheDriveFree)}"); + bool useFileCompactor = _configService.Current.UseCompactor; - bool isLinux = _dalamudUtilService.IsWine; - if (!useFileCompactor && !isLinux) + if (!useFileCompactor) { UiSharedService.ColorTextWrapped( "Hint: To free up space when using Lightless consider enabling the File Compactor", UIColors.Get("LightlessYellow")); } - if (isLinux || !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); + if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); if (ImGui.Checkbox("Use file compactor", ref useFileCompactor)) { _configService.Current.UseCompactor = useFileCompactor; @@ -1281,10 +1281,20 @@ public class SettingsUi : WindowMediatorSubscriberBase UIColors.Get("LightlessYellow")); } - if (isLinux || !_cacheMonitor.StorageisNTFS) + if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS) { ImGui.EndDisabled(); - ImGui.TextUnformatted("The file compactor is only available on Windows and NTFS drives."); + ImGui.TextUnformatted("The file compactor is only available on BTRFS and NTFS drives."); + } + + if (_cacheMonitor.StorageisNTFS) + { + ImGui.TextUnformatted("The file compactor is running on NTFS Drive."); + } + + if (_cacheMonitor.StorageIsBtrfs) + { + ImGui.TextUnformatted("The file compactor is running on Btrfs Drive."); } ImGuiHelpers.ScaledDummy(new Vector2(10, 10)); @@ -3113,22 +3123,6 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.TooltipSeparator + "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling"); - if (_dalamudUtilService.IsWine) - { - bool forceWebSockets = selectedServer.ForceWebSockets; - if (ImGui.Checkbox("[wine only] Force WebSockets", ref forceWebSockets)) - { - selectedServer.ForceWebSockets = forceWebSockets; - _serverConfigurationManager.Save(); - } - - _uiShared.DrawHelpText( - "On wine, Lightless will automatically fall back to ServerSentEvents/LongPolling, even if WebSockets is selected. " - + "WebSockets are known to crash XIV entirely on wine 8.5 shipped with Dalamud. " - + "Only enable this if you are not running wine 8.5." + Environment.NewLine - + "Note: If the issue gets resolved at some point this option will be removed."); - } - ImGuiHelpers.ScaledDummy(5); if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth)) diff --git a/LightlessSync/Utils/Crypto.cs b/LightlessSync/Utils/Crypto.cs index de04d26..87d6883 100644 --- a/LightlessSync/Utils/Crypto.cs +++ b/LightlessSync/Utils/Crypto.cs @@ -21,6 +21,26 @@ public static class Crypto return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal); } + public static async Task GetFileHashAsync(string filePath, CancellationToken cancellationToken = default) + { + var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 65536, options: FileOptions.Asynchronous); + await using (stream.ConfigureAwait(false)) + { + using var sha1 = SHA1.Create(); + + var buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0); + } + + sha1.TransformFinalBlock([], 0, 0); + + return BitConverter.ToString(sha1.Hash!).Replace("-", "", StringComparison.Ordinal); + } + } + public static string GetHash256(this (string, ushort) playerToHash) { if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash)) diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs new file mode 100644 index 0000000..a80e922 --- /dev/null +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -0,0 +1,143 @@ +using System.Collections.Concurrent; +using System.Runtime.InteropServices; + +namespace LightlessSync.Utils +{ + public static class FileSystemHelper + { + public enum FilesystemType + { + Unknown = 0, + NTFS, + Btrfs, + Ext4, + Xfs, + Apfs, + HfsPlus, + Fat, + Exfat, + Zfs + } + + private static readonly ConcurrentDictionary _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase); + + public static FilesystemType GetFilesystemType(string filePath) + { + try + { + string rootPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var info = new FileInfo(filePath); + var dir = info.Directory ?? new DirectoryInfo(filePath); + rootPath = dir.Root.FullName; + } + else + { + rootPath = GetMountPoint(filePath); + if (string.IsNullOrEmpty(rootPath)) + rootPath = "/"; + } + + if (_filesystemTypeCache.TryGetValue(rootPath, out var cachedType)) + return cachedType; + + FilesystemType detected; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var root = new DriveInfo(rootPath); + var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty; + detected = format switch + { + "NTFS" => FilesystemType.NTFS, + "FAT32" => FilesystemType.Fat, + "EXFAT" => FilesystemType.Exfat, + _ => FilesystemType.Unknown + }; + } + else + { + detected = GetLinuxFilesystemType(filePath); + } + + _filesystemTypeCache[rootPath] = detected; + return detected; + } + catch (Exception ex) + { + return FilesystemType.Unknown; + } + } + + private static string GetMountPoint(string filePath) + { + try + { + var path = Path.GetFullPath(filePath); + if (!File.Exists("/proc/mounts")) return "/"; + var mounts = File.ReadAllLines("/proc/mounts"); + + string bestMount = "/"; + foreach (var line in mounts) + { + var parts = line.Split(' '); + if (parts.Length < 3) continue; + var mountPoint = parts[1].Replace("\\040", " "); // unescape spaces + + string normalizedMount; + try { normalizedMount = Path.GetFullPath(mountPoint); } + catch { normalizedMount = mountPoint; } + + if (path.StartsWith(normalizedMount, StringComparison.Ordinal) && + normalizedMount.Length > bestMount.Length) + { + bestMount = normalizedMount; + } + } + + return bestMount; + } + catch + { + return "/"; + } + } + + private static FilesystemType GetLinuxFilesystemType(string filePath) + { + try + { + var mountPoint = GetMountPoint(filePath); + var mounts = File.ReadAllLines("/proc/mounts"); + + foreach (var line in mounts) + { + var parts = line.Split(' '); + if (parts.Length < 3) continue; + var mount = parts[1].Replace("\\040", " "); + if (string.Equals(mount, mountPoint, StringComparison.Ordinal)) + { + var fstype = parts[2].ToLowerInvariant(); + return fstype switch + { + "btrfs" => FilesystemType.Btrfs, + "ext4" => FilesystemType.Ext4, + "xfs" => FilesystemType.Xfs, + "zfs" => FilesystemType.Zfs, + "apfs" => FilesystemType.Apfs, + "hfsplus" => FilesystemType.HfsPlus, + _ => FilesystemType.Unknown + }; + } + } + + return FilesystemType.Unknown; + } + catch + { + return FilesystemType.Unknown; + } + } + } +} diff --git a/LightlessSync/WebAPI/SignalR/HubFactory.cs b/LightlessSync/WebAPI/SignalR/HubFactory.cs index ef9f919..1d5a0c8 100644 --- a/LightlessSync/WebAPI/SignalR/HubFactory.cs +++ b/LightlessSync/WebAPI/SignalR/HubFactory.cs @@ -70,13 +70,6 @@ public class HubFactory : MediatorSubscriberBase _ => HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling }; - if (_isWine && !_serverConfigurationManager.CurrentServer.ForceWebSockets - && transportType.HasFlag(HttpTransportType.WebSockets)) - { - Logger.LogDebug("Wine detected, falling back to ServerSentEvents / LongPolling"); - transportType = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling; - } - Logger.LogDebug("Building new HubConnection using transport {transport}", transportType); _instance = new HubConnectionBuilder() -- 2.49.1 From 9a846a37d40c1d3115dc7bf21438fa39a489bdac Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 04:43:18 +0100 Subject: [PATCH 009/140] Redone handling of windows compactor handling. --- LightlessSync/FileCache/FileCompactor.cs | 40 +++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index e5d219e..dbc2574 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -372,6 +372,22 @@ public sealed class FileCompactor : IDisposable return _clusterSizes[root]; } + public static bool UseSafeHandle(SafeHandle handle, Func action) + { + bool addedRef = false; + try + { + handle.DangerousAddRef(ref addedRef); + IntPtr ptr = handle.DangerousGetHandle(); + return action(ptr); + } + finally + { + if (addedRef) + handle.DangerousRelease(); + } + } + private static bool IsWOFCompactedFile(string filePath) { uint buf = 8; @@ -424,22 +440,25 @@ public sealed class FileCompactor : IDisposable ulong length = (ulong)Marshal.SizeOf(_efInfo); try { - using var fs = new FileStream(path, FileMode.Open); - #pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called - var hFile = fs.SafeFileHandle.DangerousGetHandle(); - #pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called - if (fs.SafeFileHandle.IsInvalid) + using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + var handle = fs.SafeFileHandle; + + if (handle.IsInvalid) { _logger.LogWarning("Invalid file handle to {file}", path); return false; } - var ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length); - if (!(ret == 0 || ret == unchecked((int)0x80070158))) + return UseSafeHandle(handle, hFile => { - _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); - return false; - } + int ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length); + if (ret != 0 && ret != unchecked((int)0x80070158)) + { + _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); + return false; + } + return true; + }); } catch (Exception ex) { @@ -450,7 +469,6 @@ public sealed class FileCompactor : IDisposable { Marshal.FreeHGlobal(efInfoPtr); } - return true; } private bool BtrfsCompressFile(string path) -- 2.49.1 From 3e626c5e47042c7941a9c52460c72ec98a92ac21 Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 04:49:46 +0100 Subject: [PATCH 010/140] Cleanup some code, removed ntfs usage on cache monitor --- LightlessSync/FileCache/CacheMonitor.cs | 2 +- LightlessSync/FileCache/FileCompactor.cs | 143 ++++++++++++++++++----- 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 23f5c19..c724225 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -429,7 +429,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase try { - return _fileCompactor.GetFileSizeOnDisk(f, StorageisNTFS); + return _fileCompactor.GetFileSizeOnDisk(f); } catch { diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index dbc2574..e961fbd 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -4,11 +4,8 @@ using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; -using System.IO; using System.Runtime.InteropServices; -using System.Threading; using System.Threading.Channels; -using System.Threading.Tasks; using static LightlessSync.Utils.FileSystemHelper; namespace LightlessSync.FileCache; @@ -89,27 +86,21 @@ public sealed class FileCompactor : IDisposable MassCompactRunning = false; } - public long GetFileSizeOnDisk(FileInfo fileInfo, bool? isNTFS = null) + public long GetFileSizeOnDisk(FileInfo fileInfo) { - var fsType = FileSystemHelper.GetFilesystemType(fileInfo.FullName); + var fsType = GetFilesystemType(fileInfo.FullName); - bool ntfs = isNTFS ?? fsType == FileSystemHelper.FilesystemType.NTFS; - - if (fsType != FileSystemHelper.FilesystemType.Btrfs && !ntfs) + if (fsType != FilesystemType.Btrfs && fsType != FilesystemType.NTFS) { return fileInfo.Length; } - if (ntfs && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { - var clusterSize = GetClusterSize(fileInfo); - if (clusterSize == -1) return fileInfo.Length; - var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); - var size = (long)hosize << 32 | losize; - return ((size + clusterSize - 1) / clusterSize) * clusterSize; + return GetFileSizeOnDisk(fileInfo, GetClusterSize); } - if (fsType == FileSystemHelper.FilesystemType.Btrfs) + if (fsType == FilesystemType.Btrfs) { try { @@ -163,6 +154,41 @@ public sealed class FileCompactor : IDisposable GC.SuppressFinalize(this); } + [DllImport("libc", SetLastError = true)] + private static extern int statvfs(string path, out Statvfs buf); + + [StructLayout(LayoutKind.Sequential)] + private struct Statvfs + { + public ulong f_bsize; + public ulong f_frsize; + public ulong f_blocks; + public ulong f_bfree; + public ulong f_bavail; + public ulong f_files; + public ulong f_ffree; + public ulong f_favail; + public ulong f_fsid; + public ulong f_flag; + public ulong f_namemax; + } + + private static int GetLinuxBlockSize(string path) + { + try + { + int result = statvfs(path, out var buf); + if (result != 0) + return -1; + + return (int)buf.f_frsize; + } + catch + { + return -1; + } + } + [DllImport("kernel32.dll")] private static extern int DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out IntPtr lpBytesReturned, out IntPtr lpOverlapped); @@ -190,7 +216,7 @@ public sealed class FileCompactor : IDisposable return; } - var fsType = FileSystemHelper.GetFilesystemType(filePath); + var fsType = GetFilesystemType(filePath); var oldSize = fi.Length; int clusterSize = GetClusterSize(fi); @@ -201,7 +227,7 @@ public sealed class FileCompactor : IDisposable } // NTFS Compression. - if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { if (!IsWOFCompactedFile(filePath)) { @@ -250,6 +276,17 @@ public sealed class FileCompactor : IDisposable } } + private static long GetFileSizeOnDisk(FileInfo fileInfo, Func getClusterSize) + { + int clusterSize = getClusterSize(fileInfo); + if (clusterSize <= 0) + return fileInfo.Length; + + uint low = GetCompressedFileSizeW(fileInfo.FullName, out uint high); + long compressed = ((long)high << 32) | low; + return ((compressed + clusterSize - 1) / clusterSize) * clusterSize; + } + private static long RunStatGetBlocks(string path) { var psi = new ProcessStartInfo("stat", $"-c %b \"{path}\"") @@ -280,11 +317,10 @@ public sealed class FileCompactor : IDisposable private void DecompressFile(string path) { _logger.LogDebug("Removing compression from {file}", path); - var fsType = FileSystemHelper.GetFilesystemType(path); - if (fsType == null) return; + var fsType = GetFilesystemType(path); //NTFS Decompression - if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { try { @@ -304,7 +340,7 @@ public sealed class FileCompactor : IDisposable } //BTRFS Decompression - if (fsType == FileSystemHelper.FilesystemType.Btrfs) + if (fsType == FilesystemType.Btrfs) { try { @@ -360,16 +396,60 @@ public sealed class FileCompactor : IDisposable private int GetClusterSize(FileInfo fi) { - if (!fi.Exists) return -1; - var root = fi.Directory?.Root.FullName.ToLower() ?? string.Empty; - if (string.IsNullOrEmpty(root)) return -1; - if (_clusterSizes.TryGetValue(root, out int value)) return value; - _logger.LogDebug("Getting Cluster Size for {path}, root {root}", fi.FullName, root); - int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, out uint bytesPerSector, out _, out _); - if (result == 0) return -1; - _clusterSizes[root] = (int)(sectorsPerCluster * bytesPerSector); - _logger.LogDebug("Determined Cluster Size for root {root}: {cluster}", root, _clusterSizes[root]); - return _clusterSizes[root]; + try + { + if (!fi.Exists) + return -1; + + var root = fi.Directory?.Root.FullName; + if (string.IsNullOrEmpty(root)) + return -1; + + root = root.ToLowerInvariant(); + + if (_clusterSizes.TryGetValue(root, out int cached)) + return cached; + + _logger.LogDebug("Determining cluster/block size for {path} (root: {root})", fi.FullName, root); + + int clusterSize; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + int result = GetDiskFreeSpaceW( + root, + out uint sectorsPerCluster, + out uint bytesPerSector, + out _, + out _); + + if (result == 0) + { + _logger.LogWarning("GetDiskFreeSpaceW failed for {root}", root); + return -1; + } + + clusterSize = (int)(sectorsPerCluster * bytesPerSector); + } + else + { + clusterSize = GetLinuxBlockSize(root); + if (clusterSize <= 0) + { + _logger.LogWarning("Failed to determine block size for {root}", root); + return -1; + } + } + + _clusterSizes[root] = clusterSize; + _logger.LogDebug("Determined cluster/block size for {root}: {cluster} bytes", root, clusterSize); + return clusterSize; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error determining cluster size for {file}", fi.FullName); + return -1; + } } public static bool UseSafeHandle(SafeHandle handle, Func action) @@ -418,7 +498,6 @@ public sealed class FileCompactor : IDisposable string output = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); - // look for "flags: compressed" in the output if (output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase)) { return true; -- 2.49.1 From 3f85852618c6c3143c4ebe5fa14bc687f6269d05 Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 04:52:17 +0100 Subject: [PATCH 011/140] Added string comparisons --- LightlessSync/Utils/FileSystemHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index a80e922..a80c900 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -83,7 +83,7 @@ namespace LightlessSync.Utils { var parts = line.Split(' '); if (parts.Length < 3) continue; - var mountPoint = parts[1].Replace("\\040", " "); // unescape spaces + var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); string normalizedMount; try { normalizedMount = Path.GetFullPath(mountPoint); } @@ -115,7 +115,7 @@ namespace LightlessSync.Utils { var parts = line.Split(' '); if (parts.Length < 3) continue; - var mount = parts[1].Replace("\\040", " "); + var mount = parts[1].Replace("\\040", " ", StringComparison.Ordinal); if (string.Equals(mount, mountPoint, StringComparison.Ordinal)) { var fstype = parts[2].ToLowerInvariant(); -- 2.49.1 From c37e3badf16da5a55c536d4a4621f669ebafcd71 Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 06:09:44 +0100 Subject: [PATCH 012/140] Check if wine is used. --- LightlessSync/FileCache/CacheMonitor.cs | 2 +- LightlessSync/FileCache/FileCompactor.cs | 26 +++++++++++------------- LightlessSync/Utils/FileSystemHelper.cs | 8 +++++--- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index c724225..95c3150 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -126,7 +126,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase Logger.LogWarning("Lightless file path is not set, cannot start the FSW for Lightless."); return; } - var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder); + var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine); if (fsType == FileSystemHelper.FilesystemType.NTFS) { diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index e961fbd..d1aa60a 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -4,6 +4,7 @@ using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; +using System.IO; using System.Runtime.InteropServices; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -88,7 +89,7 @@ public sealed class FileCompactor : IDisposable public long GetFileSizeOnDisk(FileInfo fileInfo) { - var fsType = GetFilesystemType(fileInfo.FullName); + var fsType = GetFilesystemType(fileInfo.FullName, _dalamudUtilService.IsWine); if (fsType != FilesystemType.Btrfs && fsType != FilesystemType.NTFS) { @@ -216,7 +217,7 @@ public sealed class FileCompactor : IDisposable return; } - var fsType = GetFilesystemType(filePath); + var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); var oldSize = fi.Length; int clusterSize = GetClusterSize(fi); @@ -252,7 +253,7 @@ public sealed class FileCompactor : IDisposable } // BTRFS Compression - if (fsType == FileSystemHelper.FilesystemType.Btrfs) + if (fsType == FilesystemType.Btrfs) { if (!IsBtrfsCompressedFile(filePath)) { @@ -317,20 +318,18 @@ public sealed class FileCompactor : IDisposable private void DecompressFile(string path) { _logger.LogDebug("Removing compression from {file}", path); - var fsType = GetFilesystemType(path); + var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine); //NTFS Decompression if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { try { - using (var fs = new FileStream(path, FileMode.Open)) - { + using var fs = new FileStream(path, FileMode.Open); #pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called - var hDevice = fs.SafeFileHandle.DangerousGetHandle(); + var hDevice = fs.SafeFileHandle.DangerousGetHandle(); #pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called - _ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _); - } + _ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _); } catch (Exception ex) { @@ -380,7 +379,6 @@ public sealed class FileCompactor : IDisposable } else { - // Log output only in debug mode to avoid clutter if (!string.IsNullOrWhiteSpace(stdout)) _logger.LogDebug("btrfs defragment output for {file}: {out}", path, stdout.Trim()); @@ -414,7 +412,7 @@ public sealed class FileCompactor : IDisposable int clusterSize; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !_dalamudUtilService.IsWine) { int result = GetDiskFreeSpaceW( root, @@ -602,7 +600,7 @@ public sealed class FileCompactor : IDisposable { var parts = line.Split(' '); if (parts.Length < 4) continue; - var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); // unescape spaces + var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); string normalized; try { normalized = Path.GetFullPath(mountPoint); } catch { normalized = mountPoint; } @@ -635,7 +633,7 @@ public sealed class FileCompactor : IDisposable if (!_pendingCompactions.TryAdd(filePath, 0)) return; - var fsType = GetFilesystemType(filePath); + var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs) { @@ -700,7 +698,7 @@ public sealed class FileCompactor : IDisposable } catch (OperationCanceledException) { - // expected during shutdown + _logger.LogDebug("Queue has been cancelled by token"); } } } diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index a80c900..a5bb427 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -21,12 +21,12 @@ namespace LightlessSync.Utils private static readonly ConcurrentDictionary _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase); - public static FilesystemType GetFilesystemType(string filePath) + public static FilesystemType GetFilesystemType(string filePath, bool isWine = false) { try { string rootPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) { var info = new FileInfo(filePath); var dir = info.Directory ?? new DirectoryInfo(filePath); @@ -44,7 +44,7 @@ namespace LightlessSync.Utils FilesystemType detected; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) { var root = new DriveInfo(rootPath); var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty; @@ -139,5 +139,7 @@ namespace LightlessSync.Utils return FilesystemType.Unknown; } } + + private static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); } } -- 2.49.1 From 7c4d0fd5e99a897c0bbaa6ae2e819cf98e17185e Mon Sep 17 00:00:00 2001 From: cake Date: Wed, 29 Oct 2025 22:54:50 +0100 Subject: [PATCH 013/140] Added comments, clean-up --- LightlessSync/FileCache/CacheMonitor.cs | 12 ++++++--- LightlessSync/FileCache/FileCompactor.cs | 31 ++++++++++++------------ LightlessSync/Utils/Crypto.cs | 27 ++++++++++----------- LightlessSync/Utils/FileSystemHelper.cs | 14 ++++++----- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 95c3150..64910f3 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -409,11 +409,17 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase return; } - FileCacheSize = -1; - DriveInfo di = new(new DirectoryInfo(_configService.Current.CacheFolder).Root.FullName); + FileCacheSize = -1; + + var drive = DriveInfo.GetDrives().FirstOrDefault(d => _configService.Current.CacheFolder.StartsWith(d.Name, StringComparison.Ordinal)); + if (drive == null) + { + return; + } + try { - FileCacheDriveFree = di.AvailableFreeSpace; + FileCacheDriveFree = drive.AvailableFreeSpace; } catch (Exception ex) { diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index d1aa60a..9a73e81 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,10 +1,8 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; -using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; -using System.IO; using System.Runtime.InteropServices; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -106,6 +104,7 @@ public sealed class FileCompactor : IDisposable try { long blocks = RunStatGetBlocks(fileInfo.FullName); + //st_blocks are always calculated in 512-byte units, hence we use 512L return blocks * 512L; } catch (Exception ex) @@ -161,17 +160,17 @@ public sealed class FileCompactor : IDisposable [StructLayout(LayoutKind.Sequential)] private struct Statvfs { - public ulong f_bsize; - public ulong f_frsize; - public ulong f_blocks; - public ulong f_bfree; - public ulong f_bavail; - public ulong f_files; - public ulong f_ffree; - public ulong f_favail; - public ulong f_fsid; - public ulong f_flag; - public ulong f_namemax; + public ulong f_bsize; /* Filesystem block size */ + public ulong f_frsize; /* Fragment size */ + public ulong f_blocks; /* Size of fs in f_frsize units */ + public ulong f_bfree; /* Number of free blocks */ + public ulong f_bavail; /* Number of free blocks for unprivileged users */ + public ulong f_files; /* Number of inodes */ + public ulong f_ffree; /* Number of free inodes */ + public ulong f_favail; /* Number of free inodes for unprivileged users */ + public ulong f_fsid; /* Filesystem ID */ + public ulong f_flag; /* Mount flags */ + public ulong f_namemax; /* Maximum filename length */ } private static int GetLinuxBlockSize(string path) @@ -182,6 +181,7 @@ public sealed class FileCompactor : IDisposable if (result != 0) return -1; + //return fragment size of linux return (int)buf.f_frsize; } catch @@ -346,9 +346,7 @@ public sealed class FileCompactor : IDisposable var mountOptions = GetMountOptionsForPath(path); if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning( - "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). " + - "Remount with 'compress=no' before running decompression.", path, mountOptions); + _logger.LogWarning("Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no' before running decompression.", path, mountOptions); return; } @@ -369,6 +367,7 @@ public sealed class FileCompactor : IDisposable return; } + //End stream of process to read the files var stdout = proc.StandardOutput.ReadToEnd(); var stderr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); diff --git a/LightlessSync/Utils/Crypto.cs b/LightlessSync/Utils/Crypto.cs index 87d6883..c31f82f 100644 --- a/LightlessSync/Utils/Crypto.cs +++ b/LightlessSync/Utils/Crypto.cs @@ -1,16 +1,15 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; namespace LightlessSync.Utils; public static class Crypto { + //This buffersize seems to be the best sweetpoint for Linux and Windows + private const int _bufferSize = 65536; #pragma warning disable SYSLIB0021 // Type or member is obsolete - private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = new(); + private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = []; private static readonly Dictionary _hashListSHA256 = new(StringComparer.Ordinal); private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new(); @@ -23,21 +22,21 @@ public static class Crypto public static async Task GetFileHashAsync(string filePath, CancellationToken cancellationToken = default) { - var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: 65536, options: FileOptions.Asynchronous); + var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous); await using (stream.ConfigureAwait(false)) { using var sha1 = SHA1.Create(); - var buffer = new byte[8192]; - int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) - { - sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0); - } + var buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0); + } - sha1.TransformFinalBlock([], 0, 0); + sha1.TransformFinalBlock([], 0, 0); - return BitConverter.ToString(sha1.Hash!).Replace("-", "", StringComparison.Ordinal); + return Convert.ToHexString(sha1.Hash!); } } diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index a5bb427..cda36c3 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -8,17 +8,18 @@ namespace LightlessSync.Utils public enum FilesystemType { Unknown = 0, - NTFS, - Btrfs, + NTFS, // Compressable + Btrfs, // Compressable Ext4, Xfs, Apfs, HfsPlus, Fat, Exfat, - Zfs + Zfs // Compressable } + private const string _mountPath = "/proc/mounts"; private static readonly ConcurrentDictionary _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase); public static FilesystemType GetFilesystemType(string filePath, bool isWine = false) @@ -75,8 +76,8 @@ namespace LightlessSync.Utils try { var path = Path.GetFullPath(filePath); - if (!File.Exists("/proc/mounts")) return "/"; - var mounts = File.ReadAllLines("/proc/mounts"); + if (!File.Exists(_mountPath)) return "/"; + var mounts = File.ReadAllLines(_mountPath); string bestMount = "/"; foreach (var line in mounts) @@ -109,7 +110,7 @@ namespace LightlessSync.Utils try { var mountPoint = GetMountPoint(filePath); - var mounts = File.ReadAllLines("/proc/mounts"); + var mounts = File.ReadAllLines(_mountPath); foreach (var line in mounts) { @@ -140,6 +141,7 @@ namespace LightlessSync.Utils } } + //Extra check on private static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); } } -- 2.49.1 From b3cc41382f28f8edf6dd49ee023a0174ae297a66 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 30 Oct 2025 03:05:53 +0100 Subject: [PATCH 014/140] Refactored a bit, added comments on the file systems. --- LightlessSync/FileCache/FileCompactor.cs | 53 +++--------------------- LightlessSync/Utils/FileSystemHelper.cs | 52 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 9a73e81..60cc78e 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,5 +1,6 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; @@ -181,7 +182,7 @@ public sealed class FileCompactor : IDisposable if (result != 0) return -1; - //return fragment size of linux + //return fragment size of Linux file system return (int)buf.f_frsize; } catch @@ -413,12 +414,7 @@ public sealed class FileCompactor : IDisposable if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !_dalamudUtilService.IsWine) { - int result = GetDiskFreeSpaceW( - root, - out uint sectorsPerCluster, - out uint bytesPerSector, - out _, - out _); + int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, out uint bytesPerSector, out _, out _); if (result == 0) { @@ -495,12 +491,10 @@ public sealed class FileCompactor : IDisposable string output = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(); - if (output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase)) - { - return true; - } + bool compressed = output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); - return false; + _logger.LogTrace("Btrfs compression check for {file}: {compressed}", path, compressed); + return compressed; } catch (Exception ex) { @@ -586,41 +580,6 @@ public sealed class FileCompactor : IDisposable } } - private string GetMountOptionsForPath(string path) - { - try - { - var fullPath = Path.GetFullPath(path); - var mounts = File.ReadAllLines("/proc/mounts"); - string bestMount = string.Empty; - string mountOptions = string.Empty; - - foreach (var line in mounts) - { - var parts = line.Split(' '); - if (parts.Length < 4) continue; - var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); - string normalized; - try { normalized = Path.GetFullPath(mountPoint); } - catch { normalized = mountPoint; } - - if (fullPath.StartsWith(normalized, StringComparison.Ordinal) && - normalized.Length > bestMount.Length) - { - bestMount = normalized; - mountOptions = parts[3]; - } - } - - return mountOptions; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get mount options for {path}", path); - return string.Empty; - } - } - private struct WOF_FILE_COMPRESSION_INFO_V1 { public CompressionAlgorithm Algorithm; diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index cda36c3..4bbfc75 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -8,15 +8,15 @@ namespace LightlessSync.Utils public enum FilesystemType { Unknown = 0, - NTFS, // Compressable - Btrfs, // Compressable - Ext4, - Xfs, - Apfs, - HfsPlus, - Fat, - Exfat, - Zfs // Compressable + NTFS, // Compressable on file level + Btrfs, // Compressable on file level + Ext4, // Uncompressable + Xfs, // Uncompressable + Apfs, // Compressable on OS + HfsPlus, // Compressable on OS + Fat, // Uncompressable + Exfat, // Uncompressable + Zfs // Compressable, not on file level } private const string _mountPath = "/proc/mounts"; @@ -105,6 +105,40 @@ namespace LightlessSync.Utils } } + public static string GetMountOptionsForPath(string path) + { + try + { + var fullPath = Path.GetFullPath(path); + var mounts = File.ReadAllLines("/proc/mounts"); + string bestMount = string.Empty; + string mountOptions = string.Empty; + + foreach (var line in mounts) + { + var parts = line.Split(' '); + if (parts.Length < 4) continue; + var mountPoint = parts[1].Replace("\\040", " ", StringComparison.Ordinal); + string normalized; + try { normalized = Path.GetFullPath(mountPoint); } + catch { normalized = mountPoint; } + + if (fullPath.StartsWith(normalized, StringComparison.Ordinal) && + normalized.Length > bestMount.Length) + { + bestMount = normalized; + mountOptions = parts[3]; + } + } + + return mountOptions; + } + catch (Exception ex) + { + return string.Empty; + } + } + private static FilesystemType GetLinuxFilesystemType(string filePath) { try -- 2.49.1 From bf139c128b75b8d744864cb9b574c74c91e65ad6 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 30 Oct 2025 03:11:38 +0100 Subject: [PATCH 015/140] Added fail safes in compact of WOF incase --- LightlessSync/FileCache/FileCompactor.cs | 32 ++++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 60cc78e..2e32a3e 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -138,7 +138,7 @@ public sealed class FileCompactor : IDisposable _compactionCts.Cancel(); try { - if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5))) + if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5), _compactionCts.Token)) { _logger.LogDebug("Compaction worker did not shut down within timeout"); } @@ -463,10 +463,32 @@ public sealed class FileCompactor : IDisposable private static bool IsWOFCompactedFile(string filePath) { - uint buf = 8; - _ = WofIsExternalFile(filePath, out int isExtFile, out uint _, out var info, ref buf); - if (isExtFile == 0) return false; - return info.Algorithm == CompressionAlgorithm.XPRESS8K; + try + { + uint buf = (uint)Marshal.SizeOf(); + int result = WofIsExternalFile(filePath, out int isExternal, out uint _, out var info, ref buf); + + if (result != 0 || isExternal == 0) + return false; + + return info.Algorithm == CompressionAlgorithm.XPRESS8K || info.Algorithm == CompressionAlgorithm.XPRESS4K + || info.Algorithm == CompressionAlgorithm.XPRESS16K || info.Algorithm == CompressionAlgorithm.LZX; + } + catch (DllNotFoundException) + { + // WofUtil.dll not available + return false; + } + catch (EntryPointNotFoundException) + { + // Running under Wine or non-NTFS systems + return false; + } + catch (Exception) + { + // Exception happened + return false; + } } private bool IsBtrfsCompressedFile(string path) -- 2.49.1 From c1770528f3c79300ac7fc42f65f862cbb72e37d9 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 30 Oct 2025 03:34:56 +0100 Subject: [PATCH 016/140] Added wine checks, path fixing on wine -> linux --- LightlessSync/FileCache/FileCompactor.cs | 105 ++++++++++++++++++++--- 1 file changed, 91 insertions(+), 14 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 2e32a3e..a8170f4 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,6 +1,5 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; -using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; @@ -191,6 +190,16 @@ public sealed class FileCompactor : IDisposable } } + private static string ConvertWinePathToLinux(string winePath) + { + if (winePath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + return "/" + winePath.Substring(3).Replace('\\', '/'); + if (winePath.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), + winePath.Substring(3).Replace('\\', '/')).Replace('\\', '/'); + return winePath.Replace('\\', '/'); + } + [DllImport("kernel32.dll")] private static extern int DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out IntPtr lpBytesReturned, out IntPtr lpOverlapped); @@ -347,18 +356,42 @@ public sealed class FileCompactor : IDisposable var mountOptions = GetMountOptionsForPath(path); if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no' before running decompression.", path, mountOptions); + _logger.LogWarning( + "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no' before running decompression.", + path, mountOptions); return; } - _logger.LogDebug("Rewriting {file} to remove btrfs compression...", path); - - var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -- \"{path}\"") + string realPath = path; + bool isWine = _dalamudUtilService?.IsWine ?? false; + if (isWine) { + if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + { + realPath = "/" + path.Substring(3).Replace('\\', '/'); + } + else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + { + // fallback for Wine's C:\ mapping + realPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), + path.Substring(3).Replace('\\', '/') + ).Replace('\\', '/'); + } + + _logger.LogTrace("Detected Wine environment. Converted path for decompression: {realPath}", realPath); + } + + string command = $"btrfs filesystem defragment -- \"{realPath}\""; + var psi = new ProcessStartInfo + { + FileName = isWine ? "/bin/bash" : "btrfs", + Arguments = isWine ? $"-c \"{command}\"" : $"filesystem defragment -- \"{realPath}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + WorkingDirectory = "/" }; using var proc = Process.Start(psi); @@ -368,7 +401,7 @@ public sealed class FileCompactor : IDisposable return; } - //End stream of process to read the files + // 4️⃣ Read process output var stdout = proc.StandardOutput.ReadToEnd(); var stderr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); @@ -495,12 +528,36 @@ public sealed class FileCompactor : IDisposable { try { - var psi = new ProcessStartInfo("filefrag", $"-v \"{path}\"") + bool isWine = _dalamudUtilService?.IsWine ?? false; + string realPath = path; + + if (isWine) { + if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + { + realPath = "/" + path.Substring(3).Replace('\\', '/'); + } + else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + { + realPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Personal), + path.Substring(3).Replace('\\', '/') + ).Replace('\\', '/'); + } + + _logger.LogTrace("Detected Wine environment. Converted path for filefrag: {realPath}", realPath); + } + + string command = $"filefrag -v -- \"{realPath}\""; + var psi = new ProcessStartInfo + { + FileName = isWine ? "/bin/bash" : "filefrag", + Arguments = isWine ? $"-c \"{command}\"" : $"-v -- \"{realPath}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + WorkingDirectory = "/" }; using var proc = Process.Start(psi); @@ -511,10 +568,17 @@ public sealed class FileCompactor : IDisposable } string output = proc.StandardOutput.ReadToEnd(); + string stderr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); - bool compressed = output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); + if (proc.ExitCode != 0) + { + _logger.LogDebug("filefrag exited with {code} for {file}. stderr: {stderr}", + proc.ExitCode, path, stderr); + return false; + } + bool compressed = output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); _logger.LogTrace("Btrfs compression check for {file}: {compressed}", path, compressed); return compressed; } @@ -567,7 +631,20 @@ public sealed class FileCompactor : IDisposable { try { - var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -czstd -- \"{path}\"") + string realPath = path; + if (_dalamudUtilService.IsWine) + { + realPath = ConvertWinePathToLinux(path); + _logger.LogTrace("Detected Wine environment, remapped path: {realPath}", realPath); + } + + if (!File.Exists("/usr/bin/btrfs") && !File.Exists("/bin/btrfs")) + { + _logger.LogWarning("Skipping Btrfs compression — btrfs binary not found"); + return false; + } + + var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -czstd -- \"{realPath}\"") { RedirectStandardOutput = true, RedirectStandardError = true, @@ -578,7 +655,7 @@ public sealed class FileCompactor : IDisposable using var proc = Process.Start(psi); if (proc == null) { - _logger.LogWarning("Failed to start btrfs process for {file}", path); + _logger.LogWarning("Failed to start btrfs process for {file}", realPath); return false; } @@ -588,7 +665,7 @@ public sealed class FileCompactor : IDisposable if (proc.ExitCode != 0) { - _logger.LogWarning("btrfs defrag returned {code} for {file}: {err}", proc.ExitCode, path, stderr); + _logger.LogWarning("btrfs defrag returned {code} for {file}: {err}", proc.ExitCode, realPath, stderr); return false; } @@ -597,7 +674,7 @@ public sealed class FileCompactor : IDisposable } catch (Exception ex) { - _logger.LogWarning(ex, "Error running btrfs defragment for {file}", path); + _logger.LogWarning(ex, "Error running btrfs defragment for {file}", realPath); return false; } } -- 2.49.1 From 5feb74c1c08594245e5a523339dc685d70c357c6 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 30 Oct 2025 03:46:55 +0100 Subject: [PATCH 017/140] Added another wine check in parralel with dalamud --- LightlessSync/FileCache/FileCompactor.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index a8170f4..5f05549 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -364,7 +364,7 @@ public sealed class FileCompactor : IDisposable string realPath = path; bool isWine = _dalamudUtilService?.IsWine ?? false; - if (isWine) + if (isWine && IsProbablyWine()) { if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) { @@ -372,7 +372,6 @@ public sealed class FileCompactor : IDisposable } else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) { - // fallback for Wine's C:\ mapping realPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.Personal), path.Substring(3).Replace('\\', '/') @@ -401,7 +400,6 @@ public sealed class FileCompactor : IDisposable return; } - // 4️⃣ Read process output var stdout = proc.StandardOutput.ReadToEnd(); var stderr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); @@ -531,7 +529,7 @@ public sealed class FileCompactor : IDisposable bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = path; - if (isWine) + if (isWine && IsProbablyWine()) { if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) { @@ -539,10 +537,7 @@ public sealed class FileCompactor : IDisposable } else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) { - realPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), - path.Substring(3).Replace('\\', '/') - ).Replace('\\', '/'); + realPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), path.Substring(3).Replace('\\', '/')).Replace('\\', '/'); } _logger.LogTrace("Detected Wine environment. Converted path for filefrag: {realPath}", realPath); @@ -632,7 +627,7 @@ public sealed class FileCompactor : IDisposable try { string realPath = path; - if (_dalamudUtilService.IsWine) + if (_dalamudUtilService.IsWine && IsProbablyWine()) { realPath = ConvertWinePathToLinux(path); _logger.LogTrace("Detected Wine environment, remapped path: {realPath}", realPath); @@ -674,7 +669,7 @@ public sealed class FileCompactor : IDisposable } catch (Exception ex) { - _logger.LogWarning(ex, "Error running btrfs defragment for {file}", realPath); + _logger.LogWarning(ex, "Error running btrfs defragment for {file}", path); return false; } } @@ -758,4 +753,6 @@ public sealed class FileCompactor : IDisposable _logger.LogDebug("Queue has been cancelled by token"); } } + + private static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); } -- 2.49.1 From 6e3c60f627c5a732dc5a15b02647b1d67989a723 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 31 Oct 2025 23:47:41 +0100 Subject: [PATCH 018/140] Changes in file compression for windows, redone linux side because wine issues. --- LightlessSync/FileCache/CacheMonitor.cs | 68 +- LightlessSync/FileCache/FileCompactor.cs | 960 ++++++++++++----------- LightlessSync/Utils/FileSystemHelper.cs | 107 ++- 3 files changed, 627 insertions(+), 508 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 64910f3..85fb30e 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -137,7 +137,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase if (fsType == FileSystemHelper.FilesystemType.Btrfs) { StorageIsBtrfs = true; - Logger.LogInformation("Lightless Storage is on BTRFS drive: {isNtfs}", StorageIsBtrfs); + Logger.LogInformation("Lightless Storage is on BTRFS drive: {isBtrfs}", StorageIsBtrfs); } Logger.LogDebug("Initializing Lightless FSW on {path}", lightlessPath); @@ -661,44 +661,44 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase if (ct.IsCancellationRequested) return; - // scan new files - if (allScannedFiles.Any(c => !c.Value)) + var newFiles = allScannedFiles.Where(c => !c.Value).Select(c => c.Key).ToList(); + foreach (var cachePath in newFiles) { - Parallel.ForEach(allScannedFiles.Where(c => !c.Value).Select(c => c.Key), - new ParallelOptions() - { - MaxDegreeOfParallelism = threadCount, - CancellationToken = ct - }, (cachePath) => - { - if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null) - { - Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null); - return; - } + if (ct.IsCancellationRequested) break; + ProcessOne(cachePath); + Interlocked.Increment(ref _currentFileProgress); + } - if (ct.IsCancellationRequested) return; + Logger.LogTrace("Scanner added {count} new files to db", newFiles.Count); - if (!_ipcManager.Penumbra.APIAvailable) - { - Logger.LogWarning("Penumbra not available"); - return; - } + void ProcessOne(string? cachePath) + { + if (_fileDbManager == null || _ipcManager?.Penumbra == null || cachePath == null) + { + Logger.LogTrace("Potential null in db: {isDbNull} penumbra: {isPenumbraNull} cachepath: {isPathNull}", + _fileDbManager == null, _ipcManager?.Penumbra == null, cachePath == null); + return; + } - try - { - var entry = _fileDbManager.CreateFileEntry(cachePath); - if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed adding {file}", cachePath); - } + if (!_ipcManager.Penumbra.APIAvailable) + { + Logger.LogWarning("Penumbra not available"); + return; + } - Interlocked.Increment(ref _currentFileProgress); - }); - - Logger.LogTrace("Scanner added {notScanned} new files to db", allScannedFiles.Count(c => !c.Value)); + try + { + var entry = _fileDbManager.CreateFileEntry(cachePath); + if (entry == null) _ = _fileDbManager.CreateCacheEntry(cachePath); + } + catch (IOException ioex) + { + Logger.LogDebug(ioex, "File busy or locked: {file}", cachePath); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed adding {file}", cachePath); + } } Logger.LogDebug("Scan complete"); diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 5f05549..a917b59 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,6 +1,7 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using Microsoft.Extensions.Logging; +using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; @@ -14,44 +15,21 @@ public sealed class FileCompactor : IDisposable public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const ulong WOF_PROVIDER_FILE = 2UL; - private readonly Dictionary _clusterSizes; private readonly ConcurrentDictionary _pendingCompactions; - private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo; private readonly ILogger _logger; - private readonly LightlessConfigService _lightlessConfigService; private readonly DalamudUtilService _dalamudUtilService; + private readonly Channel _compactionQueue; private readonly CancellationTokenSource _compactionCts = new(); private readonly Task _compactionWorker; - - public FileCompactor(ILogger logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) + + private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo = new() { - _clusterSizes = new(StringComparer.Ordinal); - _pendingCompactions = new(StringComparer.OrdinalIgnoreCase); - _logger = logger; - _lightlessConfigService = lightlessConfigService; - _dalamudUtilService = dalamudUtilService; - _efInfo = new WOF_FILE_COMPRESSION_INFO_V1 - { - Algorithm = CompressionAlgorithm.XPRESS8K, - Flags = 0 - }; - - _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - _compactionWorker = Task.Factory.StartNew( - () => ProcessQueueAsync(_compactionCts.Token), - _compactionCts.Token, - TaskCreationOptions.LongRunning, - TaskScheduler.Default) - .Unwrap(); - } - - private enum CompressionAlgorithm + Algorithm = (int)CompressionAlgorithm.XPRESS8K, + Flags = 0 + }; + private enum CompressionAlgorithm { NO_COMPRESSION = -2, LZNT1 = -1, @@ -61,493 +39,500 @@ public sealed class FileCompactor : IDisposable XPRESS16K = 3 } - public bool MassCompactRunning { get; private set; } = false; + public FileCompactor(ILogger logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) + { + _pendingCompactions = new(StringComparer.OrdinalIgnoreCase); + _logger = logger; + _lightlessConfigService = lightlessConfigService; + _dalamudUtilService = dalamudUtilService; + _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false + }); + + _compactionWorker = Task.Factory.StartNew(() => ProcessQueueAsync(_compactionCts.Token), _compactionCts.Token, TaskCreationOptions.LongRunning,TaskScheduler.Default).Unwrap(); + } + + public bool MassCompactRunning { get; private set; } public string Progress { get; private set; } = string.Empty; public void CompactStorage(bool compress) { MassCompactRunning = true; - - int currentFile = 1; - var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList(); - int allFilesCount = allFiles.Count; - foreach (var file in allFiles) + try { - Progress = $"{currentFile}/{allFilesCount}"; - if (compress) - CompactFile(file); - else - DecompressFile(file); - currentFile++; - } + var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList(); + int total = allFiles.Count; + int current = 0; - MassCompactRunning = false; + foreach (var file in allFiles) + { + current++; + Progress = $"{current}/{total}"; + + try + { + if (compress) + CompactFile(file); + else + DecompressFile(file); + } + catch (IOException ioEx) + { + _logger.LogDebug(ioEx, "File {file} locked or busy, skipping", file); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error compacting/decompressing file {file}", file); + } + } + } + finally + { + MassCompactRunning = false; + Progress = string.Empty; + } } + /// + /// Write all bytes into a directory async + /// + /// Bytes will be writen to this filepath + /// Bytes that have to be written + /// Cancellation Token for interupts + /// Writing Task + public async Task WriteAllBytesAsync(string filePath, byte[] bytes, CancellationToken token) + { + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); + + if (_lightlessConfigService.Current.UseCompactor) + EnqueueCompaction(filePath); + } + + /// + /// Gets the File size for an BTRFS or NTFS file system for the given FileInfo + /// + /// Amount of blocks used in the disk public long GetFileSizeOnDisk(FileInfo fileInfo) { var fsType = GetFilesystemType(fileInfo.FullName, _dalamudUtilService.IsWine); - if (fsType != FilesystemType.Btrfs && fsType != FilesystemType.NTFS) - { - return fileInfo.Length; - } - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { - return GetFileSizeOnDisk(fileInfo, GetClusterSize); + var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); + var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); + var size = (long)hosize << 32 | losize; + return ((size + blockSize - 1) / blockSize) * blockSize; } if (fsType == FilesystemType.Btrfs) { try { - long blocks = RunStatGetBlocks(fileInfo.FullName); - //st_blocks are always calculated in 512-byte units, hence we use 512L + var realPath = fileInfo.FullName.Replace("\"", "\\\"", StringComparison.Ordinal); + var psi = new ProcessStartInfo("stat", $"-c %b \"{realPath}\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start stat"); + var outp = proc.StandardOutput.ReadToEnd(); + var err = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + throw new InvalidOperationException($"stat failed: {err}"); + + if (!long.TryParse(outp.Trim(), out var blocks)) + throw new InvalidOperationException($"invalid stat output: {outp}"); + + // st_blocks are always 512-byte on Linux enviroment. return blocks * 512L; } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to get on-disk size via stat for {file}, falling back to Length", fileInfo.FullName); - return fileInfo.Length; + _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); } } return fileInfo.Length; } - public async Task WriteAllBytesAsync(string filePath, byte[] decompressedFile, CancellationToken token) - { - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - await File.WriteAllBytesAsync(filePath, decompressedFile, token).ConfigureAwait(false); - - if (!_lightlessConfigService.Current.UseCompactor) - return; - - EnqueueCompaction(filePath); - } - - public void Dispose() - { - _compactionQueue.Writer.TryComplete(); - _compactionCts.Cancel(); - try - { - if (!_compactionWorker.Wait(TimeSpan.FromSeconds(5), _compactionCts.Token)) - { - _logger.LogDebug("Compaction worker did not shut down within timeout"); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogDebug(ex, "Error shutting down compaction worker"); - } - finally - { - _compactionCts.Dispose(); - } - - GC.SuppressFinalize(this); - } - - [DllImport("libc", SetLastError = true)] - private static extern int statvfs(string path, out Statvfs buf); - - [StructLayout(LayoutKind.Sequential)] - private struct Statvfs - { - public ulong f_bsize; /* Filesystem block size */ - public ulong f_frsize; /* Fragment size */ - public ulong f_blocks; /* Size of fs in f_frsize units */ - public ulong f_bfree; /* Number of free blocks */ - public ulong f_bavail; /* Number of free blocks for unprivileged users */ - public ulong f_files; /* Number of inodes */ - public ulong f_ffree; /* Number of free inodes */ - public ulong f_favail; /* Number of free inodes for unprivileged users */ - public ulong f_fsid; /* Filesystem ID */ - public ulong f_flag; /* Mount flags */ - public ulong f_namemax; /* Maximum filename length */ - } - - private static int GetLinuxBlockSize(string path) - { - try - { - int result = statvfs(path, out var buf); - if (result != 0) - return -1; - - //return fragment size of Linux file system - return (int)buf.f_frsize; - } - catch - { - return -1; - } - } - - private static string ConvertWinePathToLinux(string winePath) - { - if (winePath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - return "/" + winePath.Substring(3).Replace('\\', '/'); - if (winePath.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), - winePath.Substring(3).Replace('\\', '/')).Replace('\\', '/'); - return winePath.Replace('\\', '/'); - } - - [DllImport("kernel32.dll")] - private static extern int DeviceIoControl(IntPtr hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out IntPtr lpBytesReturned, out IntPtr lpOverlapped); - - [DllImport("kernel32.dll")] - private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName, - [Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh); - - [DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)] - private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, - out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, - out uint lpTotalNumberOfClusters); - - [DllImport("WoFUtil.dll")] - private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength); - - [DllImport("WofUtil.dll")] - private static extern int WofSetFileDataLocation(IntPtr FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length); - + /// + /// Compressing the given path with BTRFS or NTFS file system. + /// + /// Path of the decompressed/normal file private void CompactFile(string filePath) { var fi = new FileInfo(filePath); if (!fi.Exists) { - _logger.LogDebug("Skipping compaction for missing file {file}", filePath); + _logger.LogTrace("Skip compact: missing {file}", filePath); return; } var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + _logger.LogTrace("Detected filesystem {fs} for {file} (isWine={wine})", fsType, filePath, _dalamudUtilService.IsWine); var oldSize = fi.Length; - int clusterSize = GetClusterSize(fi); - if (oldSize < Math.Max(clusterSize, 8 * 1024)) + int blockSize = GetBlockSizeForPath(fi.FullName, _logger, _dalamudUtilService.IsWine); + if (oldSize < Math.Max(blockSize, 8 * 1024)) { - _logger.LogDebug("File {file} is smaller than cluster size ({size}), ignoring", filePath, clusterSize); + _logger.LogTrace("Skip compact: {file} < block {block}", filePath, blockSize); return; } - // NTFS Compression. if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { if (!IsWOFCompactedFile(filePath)) { - _logger.LogDebug("Compacting file to XPRESS8K: {file}", filePath); - var success = WOFCompressFile(filePath); - - if (success) + _logger.LogDebug("NTFS compact XPRESS8K: {file}", filePath); + if (WOFCompressFile(filePath)) { var newSize = GetFileSizeOnDisk(fi); - _logger.LogDebug("Compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize); + _logger.LogDebug("NTFS compressed {file} {old} -> {new}", filePath, oldSize, newSize); } else { - _logger.LogWarning("NTFS compression failed or not available for {file}", filePath); + _logger.LogWarning("NTFS compression failed or unavailable for {file}", filePath); } - } else { - _logger.LogDebug("File {file} already compressed (NTFS)", filePath); - } - } - - // BTRFS Compression - if (fsType == FilesystemType.Btrfs) - { - if (!IsBtrfsCompressedFile(filePath)) - { - _logger.LogDebug("Attempting btrfs compression for {file}", filePath); - var success = BtrfsCompressFile(filePath); - - if (success) - { - var newSize = GetFileSizeOnDisk(fi); - _logger.LogDebug("Btrfs-compressed {file} from {orig}b to {comp}b", filePath, oldSize, newSize); - } - else - { - _logger.LogWarning("Btrfs compression failed or not available for {file}", filePath); - } - } - else - { - _logger.LogDebug("File {file} already compressed (Btrfs)", filePath); - } - } - } - - private static long GetFileSizeOnDisk(FileInfo fileInfo, Func getClusterSize) - { - int clusterSize = getClusterSize(fileInfo); - if (clusterSize <= 0) - return fileInfo.Length; - - uint low = GetCompressedFileSizeW(fileInfo.FullName, out uint high); - long compressed = ((long)high << 32) | low; - return ((compressed + clusterSize - 1) / clusterSize) * clusterSize; - } - - private static long RunStatGetBlocks(string path) - { - var psi = new ProcessStartInfo("stat", $"-c %b \"{path}\"") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start stat process"); - var outp = proc.StandardOutput.ReadToEnd(); - var err = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); - if (proc.ExitCode != 0) - { - throw new InvalidOperationException($"stat failed: {err}"); - } - - if (!long.TryParse(outp.Trim(), out var blocks)) - { - throw new InvalidOperationException($"invalid stat output: {outp}"); - } - - return blocks; - } - - private void DecompressFile(string path) - { - _logger.LogDebug("Removing compression from {file}", path); - var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine); - - //NTFS Decompression - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) - { - try - { - using var fs = new FileStream(path, FileMode.Open); -#pragma warning disable S3869 // "SafeHandle.DangerousGetHandle" should not be called - var hDevice = fs.SafeFileHandle.DangerousGetHandle(); -#pragma warning restore S3869 // "SafeHandle.DangerousGetHandle" should not be called - _ = DeviceIoControl(hDevice, FSCTL_DELETE_EXTERNAL_BACKING, nint.Zero, 0, nint.Zero, 0, out _, out _); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error decompressing file {path}", path); + _logger.LogTrace("Already NTFS-compressed: {file}", filePath); } return; } - //BTRFS Decompression if (fsType == FilesystemType.Btrfs) { - try + if (!IsBtrfsCompressedFile(filePath)) { - var mountOptions = GetMountOptionsForPath(path); - if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) + _logger.LogDebug("Btrfs compress zstd: {file}", filePath); + if (BtrfsCompressFile(filePath)) { - _logger.LogWarning( - "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no' before running decompression.", - path, mountOptions); - return; - } - - string realPath = path; - bool isWine = _dalamudUtilService?.IsWine ?? false; - if (isWine && IsProbablyWine()) - { - if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = "/" + path.Substring(3).Replace('\\', '/'); - } - else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.Personal), - path.Substring(3).Replace('\\', '/') - ).Replace('\\', '/'); - } - - _logger.LogTrace("Detected Wine environment. Converted path for decompression: {realPath}", realPath); - } - - string command = $"btrfs filesystem defragment -- \"{realPath}\""; - var psi = new ProcessStartInfo - { - FileName = isWine ? "/bin/bash" : "btrfs", - Arguments = isWine ? $"-c \"{command}\"" : $"filesystem defragment -- \"{realPath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - - using var proc = Process.Start(psi); - if (proc == null) - { - _logger.LogWarning("Failed to start btrfs defragment for decompression of {file}", path); - return; - } - - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); - - if (proc.ExitCode != 0) - { - _logger.LogWarning("btrfs defragment failed for {file}: {err}", path, stderr); + var newSize = GetFileSizeOnDisk(fi); + _logger.LogDebug("Btrfs compressed {file} {old} -> {new}", filePath, oldSize, newSize); } else { - if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogDebug("btrfs defragment output for {file}: {out}", path, stdout.Trim()); + _logger.LogWarning("Btrfs compression failed or unavailable for {file}", filePath); + } + } + else + { + _logger.LogTrace("Already Btrfs-compressed: {file}", filePath); + } + return; + } - _logger.LogInformation("Decompressed (rewritten uncompressed) btrfs file: {file}", path); + _logger.LogTrace("Skip compact: unsupported FS for {file}", filePath); + } + + /// + /// Decompressing the given path with BTRFS file system or NTFS file system. + /// + /// Path of the compressed file + private void DecompressFile(string path) + { + _logger.LogDebug("Decompress request: {file}", path); + var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine); + + if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + { + try + { + bool flowControl = DecompressWOFFile(path, out FileStream fs); + if (!flowControl) + { + return; } } catch (Exception ex) { - _logger.LogWarning(ex, "Error rewriting {file} for decompression", path); + _logger.LogWarning(ex, "NTFS decompress error {file}", path); + } + } + + if (fsType == FilesystemType.Btrfs) + { + try + { + bool flowControl = DecompressBtrfsFile(path); + if (!flowControl) + { + return; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Btrfs decompress error {file}", path); } } } - private int GetClusterSize(FileInfo fi) + /// + /// Decompress an BTRFS File + /// + /// Path of the compressed file + /// Decompessing state + private bool DecompressBtrfsFile(string path) { try { - if (!fi.Exists) - return -1; - - var root = fi.Directory?.Root.FullName; - if (string.IsNullOrEmpty(root)) - return -1; - - root = root.ToLowerInvariant(); - - if (_clusterSizes.TryGetValue(root, out int cached)) - return cached; - - _logger.LogDebug("Determining cluster/block size for {path} (root: {root})", fi.FullName, root); - - int clusterSize; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !_dalamudUtilService.IsWine) + var opts = GetMountOptionsForPath(path); + if (opts.Contains("compress", StringComparison.OrdinalIgnoreCase)) { - int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, out uint bytesPerSector, out _, out _); - - if (result == 0) - { - _logger.LogWarning("GetDiskFreeSpaceW failed for {root}", root); - return -1; - } - - clusterSize = (int)(sectorsPerCluster * bytesPerSector); - } - else - { - clusterSize = GetLinuxBlockSize(root); - if (clusterSize <= 0) - { - _logger.LogWarning("Failed to determine block size for {root}", root); - return -1; - } + _logger.LogWarning("Cannot safely decompress {file}: mount options include compression ({opts})", path, opts); + return false; } - _clusterSizes[root] = clusterSize; - _logger.LogDebug("Determined cluster/block size for {root}: {cluster} bytes", root, clusterSize); - return clusterSize; + string realPath = ToLinuxPathIfWine(path, _dalamudUtilService.IsWine); + var psi = new ProcessStartInfo + { + FileName = _dalamudUtilService.IsWine ? "/bin/bash" : "btrfs", + Arguments = _dalamudUtilService.IsWine + ? $"-c \"btrfs filesystem defragment -- '{EscapeSingle(realPath)}'\"" + : $"filesystem defragment -- \"{realPath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = "/" + }; + + using var proc = Process.Start(psi); + if (proc == null) + { + _logger.LogWarning("Failed to start btrfs defragment for {file}", path); + return false; + } + + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0) + { + _logger.LogWarning("btrfs defragment failed for {file}: {err}", path, stderr); + return false; + } + + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogTrace("btrfs defragment output {file}: {out}", path, stdout.Trim()); + + _logger.LogInformation("Btrfs rewritten uncompressed: {file}", path); } catch (Exception ex) { - _logger.LogWarning(ex, "Error determining cluster size for {file}", fi.FullName); - return -1; + _logger.LogWarning(ex, "Btrfs decompress error {file}", path); } + + return true; } - public static bool UseSafeHandle(SafeHandle handle, Func action) + /// + /// Decompress an NTFS File + /// + /// Path of the compressed file + /// Decompessing state + private bool DecompressWOFFile(string path, out FileStream fs) { - bool addedRef = false; + fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + var handle = fs.SafeFileHandle; + + if (handle.IsInvalid) + { + _logger.LogWarning("Invalid handle: {file}", path); + return false; + } + + if (!DeviceIoControl(handle, FSCTL_DELETE_EXTERNAL_BACKING, + IntPtr.Zero, 0, IntPtr.Zero, 0, + out _, IntPtr.Zero)) + { + int err = Marshal.GetLastWin32Error(); + + if (err == 342) + { + _logger.LogTrace("File {file} not externally backed (already decompressed)", path); + } + else + { + _logger.LogWarning("DeviceIoControl failed for {file} with Win32 error {err}", path, err); + } + } + else + { + _logger.LogTrace("Successfully decompressed NTFS file {file}", path); + } + + return true; + } + + private static string EscapeSingle(string p) => p.Replace("'", "'\\'", StringComparison.Ordinal); + + private static string ToLinuxPathIfWine(string path, bool isWine) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return path; + + if (!IsProbablyWine() && !isWine) + return path; + + string realPath = path; + if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + realPath = "/" + path[3..].Replace('\\', '/'); + else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + realPath = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/home", path[3..].Replace('\\', '/')).Replace('\\', '/'); + + return realPath; + } + + /// + /// Compress an WOF File + /// + /// Path of the decompressed/normal file + /// Compessing state + private bool WOFCompressFile(string path) + { + FileStream? fs = null; + int size = Marshal.SizeOf(); + IntPtr efInfoPtr = Marshal.AllocHGlobal(size); + try { - handle.DangerousAddRef(ref addedRef); - IntPtr ptr = handle.DangerousGetHandle(); - return action(ptr); + Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: false); + ulong length = (ulong)size; + + const int maxRetries = 3; + + for (int attempt = 0; attempt < maxRetries; attempt++) + { + try + { + fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + break; + } + catch (IOException) + { + if (attempt == maxRetries - 1) + { + _logger.LogWarning("File still in use after {attempts} attempts, skipping compression for {file}", maxRetries, path); + return false; + } + + int delay = 150 * (attempt + 1); + _logger.LogTrace("File in use, retrying in {delay}ms for {file}", delay, path); + Thread.Sleep(delay); + } + } + + if (fs == null) + { + _logger.LogWarning("Failed to open {file} for compression; skipping", path); + return false; + } + + var handle = fs.SafeFileHandle; + + if (handle.IsInvalid) + { + _logger.LogWarning("Invalid file handle for {file}", path); + return false; + } + + int ret = WofSetFileDataLocation(handle, WOF_PROVIDER_FILE, efInfoPtr, length); + + // 0x80070158 is WOF error whenever compression fails in an non-fatal way. + if (ret != 0 && ret != unchecked((int)0x80070158)) + { + _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); + return false; + } + + return true; + } + catch (DllNotFoundException) + { + _logger.LogTrace("WofUtil.dll not available; skipping NTFS compaction for {file}", path); + return false; + } + catch (EntryPointNotFoundException) + { + _logger.LogTrace("WOF entrypoint missing (Wine/older OS); skipping NTFS compaction for {file}", path); + return false; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error compacting file {path}", path); + return false; } finally { - if (addedRef) - handle.DangerousRelease(); + fs?.Dispose(); + + if (efInfoPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(efInfoPtr); + } } } + /// + /// Checks if an File is compacted with WOF compression + /// + /// Path of the file + /// State of the file private static bool IsWOFCompactedFile(string filePath) { try { uint buf = (uint)Marshal.SizeOf(); - int result = WofIsExternalFile(filePath, out int isExternal, out uint _, out var info, ref buf); - + int result = WofIsExternalFile(filePath, out int isExternal, out _, out var info, ref buf); if (result != 0 || isExternal == 0) return false; - return info.Algorithm == CompressionAlgorithm.XPRESS8K || info.Algorithm == CompressionAlgorithm.XPRESS4K - || info.Algorithm == CompressionAlgorithm.XPRESS16K || info.Algorithm == CompressionAlgorithm.LZX; + return info.Algorithm == (int)CompressionAlgorithm.XPRESS8K + || info.Algorithm == (int)CompressionAlgorithm.XPRESS4K + || info.Algorithm == (int)CompressionAlgorithm.XPRESS16K + || info.Algorithm == (int)CompressionAlgorithm.LZX + || info.Algorithm == (int)CompressionAlgorithm.LZNT1 + || info.Algorithm == (int)CompressionAlgorithm.NO_COMPRESSION; } - catch (DllNotFoundException) + catch { - // WofUtil.dll not available - return false; - } - catch (EntryPointNotFoundException) - { - // Running under Wine or non-NTFS systems - return false; - } - catch (Exception) - { - // Exception happened return false; } } + /// + /// Checks if an File is compacted with Btrfs compression + /// + /// Path of the file + /// State of the file private bool IsBtrfsCompressedFile(string path) { try { bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = path; + string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - if (isWine && IsProbablyWine()) - { - if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = "/" + path.Substring(3).Replace('\\', '/'); - } - else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), path.Substring(3).Replace('\\', '/')).Replace('\\', '/'); - } - - _logger.LogTrace("Detected Wine environment. Converted path for filefrag: {realPath}", realPath); - } - - string command = $"filefrag -v -- \"{realPath}\""; var psi = new ProcessStartInfo { FileName = isWine ? "/bin/bash" : "filefrag", - Arguments = isWine ? $"-c \"{command}\"" : $"-v -- \"{realPath}\"", + Arguments = isWine + ? $"-c \"filefrag -v '{EscapeSingle(realPath)}'\"" + : $"-v \"{realPath}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -562,109 +547,116 @@ public sealed class FileCompactor : IDisposable return false; } - string output = proc.StandardOutput.ReadToEnd(); + string stdout = proc.StandardOutput.ReadToEnd(); string stderr = proc.StandardError.ReadToEnd(); proc.WaitForExit(); - if (proc.ExitCode != 0) + if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) { - _logger.LogDebug("filefrag exited with {code} for {file}. stderr: {stderr}", - proc.ExitCode, path, stderr); - return false; + _logger.LogTrace("filefrag exited with code {code}: {stderr}", proc.ExitCode, stderr); } - bool compressed = output.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); + bool compressed = stdout.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); _logger.LogTrace("Btrfs compression check for {file}: {compressed}", path, compressed); return compressed; } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to detect btrfs compression for {file}", path); + _logger.LogDebug(ex, "Failed to detect Btrfs compression for {file}", path); return false; } } - private bool WOFCompressFile(string path) - { - var efInfoPtr = Marshal.AllocHGlobal(Marshal.SizeOf(_efInfo)); - Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: true); - ulong length = (ulong)Marshal.SizeOf(_efInfo); - try - { - using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); - var handle = fs.SafeFileHandle; - - if (handle.IsInvalid) - { - _logger.LogWarning("Invalid file handle to {file}", path); - return false; - } - - return UseSafeHandle(handle, hFile => - { - int ret = WofSetFileDataLocation(hFile, WOF_PROVIDER_FILE, efInfoPtr, length); - if (ret != 0 && ret != unchecked((int)0x80070158)) - { - _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); - return false; - } - return true; - }); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error compacting file {path}", path); - return false; - } - finally - { - Marshal.FreeHGlobal(efInfoPtr); - } - } - + /// + /// Compress an Btrfs File + /// + /// Path of the decompressed/normal file + /// Compessing state private bool BtrfsCompressFile(string path) { try { - string realPath = path; - if (_dalamudUtilService.IsWine && IsProbablyWine()) + bool isWine = _dalamudUtilService?.IsWine ?? false; + string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; + + if (isWine && IsProbablyWine()) { - realPath = ConvertWinePathToLinux(path); - _logger.LogTrace("Detected Wine environment, remapped path: {realPath}", realPath); + if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + { + realPath = "/" + path[3..].Replace('\\', '/'); + } + else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + { + string linuxHome = Environment.GetEnvironmentVariable("HOME") ?? "/home"; + realPath = Path.Combine(linuxHome, path[3..].Replace('\\', '/')).Replace('\\', '/'); + } + + _logger.LogTrace("Detected Wine environment. Converted path for compression: {realPath}", realPath); } - if (!File.Exists("/usr/bin/btrfs") && !File.Exists("/bin/btrfs")) + const int maxRetries = 3; + for (int attempt = 0; attempt < maxRetries; attempt++) { - _logger.LogWarning("Skipping Btrfs compression — btrfs binary not found"); - return false; + try + { + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + break; + } + catch (IOException) + { + if (attempt == maxRetries - 1) + { + _logger.LogWarning("File still in use after {attempts} attempts; skipping btrfs compression for {file}", maxRetries, path); + return false; + } + + int delay = 150 * (attempt + 1); + _logger.LogTrace("File busy, retrying in {delay}ms for {file}", delay, path); + Thread.Sleep(delay); + } } - var psi = new ProcessStartInfo("btrfs", $"filesystem defragment -czstd -- \"{realPath}\"") + string command = $"btrfs filesystem defragment -czstd -- \"{realPath}\""; + var psi = new ProcessStartInfo { + FileName = isWine ? "/bin/bash" : "btrfs", + Arguments = isWine ? $"-c \"{command}\"" : $"filesystem defragment -czstd -- \"{realPath}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + WorkingDirectory = "/" }; using var proc = Process.Start(psi); if (proc == null) { - _logger.LogWarning("Failed to start btrfs process for {file}", realPath); + _logger.LogWarning("Failed to start btrfs defragment for compression of {file}", path); return false; } - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); + string stdout = proc.StandardOutput.ReadToEnd(); + string stderr = proc.StandardError.ReadToEnd(); + + try + { + proc.WaitForExit(); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Process.WaitForExit threw under Wine for {file}", path); + } if (proc.ExitCode != 0) { - _logger.LogWarning("btrfs defrag returned {code} for {file}: {err}", proc.ExitCode, realPath, stderr); + _logger.LogWarning("btrfs defragment failed for {file}: {stderr}", path, stderr); return false; } - _logger.LogDebug("btrfs output: {out}", stdout); + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogTrace("btrfs defragment output for {file}: {stdout}", path, stdout.Trim()); + + _logger.LogInformation("Compressed btrfs file successfully: {file}", path); return true; } catch (Exception ex) @@ -674,22 +666,15 @@ public sealed class FileCompactor : IDisposable } } - private struct WOF_FILE_COMPRESSION_INFO_V1 - { - public CompressionAlgorithm Algorithm; - public ulong Flags; - } - private void EnqueueCompaction(string filePath) { if (!_pendingCompactions.TryAdd(filePath, 0)) return; - - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs) { - _logger.LogTrace("Skipping compaction enqueue for unsupported filesystem {fs} ({file})", fsType, filePath); + _logger.LogTrace("Skip enqueue (unsupported fs) {fs} {file}", fsType, filePath); _pendingCompactions.TryRemove(filePath, out _); return; } @@ -697,11 +682,7 @@ public sealed class FileCompactor : IDisposable if (!_compactionQueue.Writer.TryWrite(filePath)) { _pendingCompactions.TryRemove(filePath, out _); - _logger.LogDebug("Failed to enqueue compaction job for {file}", filePath); - } - else - { - _logger.LogTrace("Queued compaction job for {file} (fs={fs})", filePath, fsType); + _logger.LogDebug("Failed to enqueue compaction {file}", filePath); } } @@ -727,13 +708,13 @@ public sealed class FileCompactor : IDisposable if (!File.Exists(filePath)) { - _logger.LogTrace("Skipping compaction for missing file {file}", filePath); + _logger.LogTrace("Skip compact (missing) {file}", filePath); continue; } CompactFile(filePath); } - catch (OperationCanceledException) + catch (OperationCanceledException) { return; } @@ -750,9 +731,46 @@ public sealed class FileCompactor : IDisposable } catch (OperationCanceledException) { - _logger.LogDebug("Queue has been cancelled by token"); + _logger.LogDebug("Compaction queue cancelled"); } } - private static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct WOF_FILE_COMPRESSION_INFO_V1 + { + public int Algorithm; + public ulong Flags; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); + + [DllImport("kernel32.dll")] + private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName, [Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh); + + [DllImport("WofUtil.dll")] + private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength); + + [DllImport("WofUtil.dll", SetLastError = true)] + private static extern int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length); + + public void Dispose() + { + _compactionQueue.Writer.TryComplete(); + _compactionCts.Cancel(); + try + { + _compactionWorker.Wait(TimeSpan.FromSeconds(5)); + } + catch + { + //ignore on catch ^^ + } + finally + { + _compactionCts.Dispose(); + } + + GC.SuppressFinalize(this); + } } diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index 4bbfc75..af4c98b 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -1,4 +1,6 @@ -using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Diagnostics; using System.Runtime.InteropServices; namespace LightlessSync.Utils @@ -20,6 +22,8 @@ namespace LightlessSync.Utils } private const string _mountPath = "/proc/mounts"; + private const int _defaultBlockSize = 4096; + private static readonly Dictionary _blockSizeCache = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary _filesystemTypeCache = new(StringComparer.OrdinalIgnoreCase); public static FilesystemType GetFilesystemType(string filePath, bool isWine = false) @@ -27,6 +31,7 @@ namespace LightlessSync.Utils try { string rootPath; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) { var info = new FileInfo(filePath); @@ -49,6 +54,7 @@ namespace LightlessSync.Utils { var root = new DriveInfo(rootPath); var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty; + detected = format switch { "NTFS" => FilesystemType.NTFS, @@ -62,10 +68,28 @@ namespace LightlessSync.Utils detected = GetLinuxFilesystemType(filePath); } + if (isWine || IsProbablyWine()) + { + switch (detected) + { + case FilesystemType.NTFS: + case FilesystemType.Unknown: + { + var linuxDetected = GetLinuxFilesystemType(filePath); + if (linuxDetected != FilesystemType.Unknown) + { + detected = linuxDetected; + } + + break; + } + } + } + _filesystemTypeCache[rootPath] = detected; return detected; } - catch (Exception ex) + catch { return FilesystemType.Unknown; } @@ -175,7 +199,84 @@ namespace LightlessSync.Utils } } + public static int GetBlockSizeForPath(string path, ILogger? logger = null, bool isWine = false) + { + try + { + if (string.IsNullOrWhiteSpace(path)) + return _defaultBlockSize; + + var fi = new FileInfo(path); + if (!fi.Exists) + return _defaultBlockSize; + + var root = fi.Directory?.Root.FullName.ToLowerInvariant() ?? "/"; + if (_blockSizeCache.TryGetValue(root, out int cached)) + return cached; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine) + { + int result = GetDiskFreeSpaceW(root, + out uint sectorsPerCluster, + out uint bytesPerSector, + out _, + out _); + + if (result == 0) + { + logger?.LogWarning("Failed to determine block size for {root}", root); + return _defaultBlockSize; + } + + int clusterSize = (int)(sectorsPerCluster * bytesPerSector); + _blockSizeCache[root] = clusterSize; + logger?.LogTrace("NTFS cluster size for {root}: {cluster}", root, clusterSize); + return clusterSize; + } + + string realPath = fi.FullName; + if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + { + realPath = "/" + realPath.Substring(3).Replace('\\', '/'); + } + + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = "/" + }; + + using var proc = Process.Start(psi); + string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? ""; + proc?.WaitForExit(); + + if (int.TryParse(stdout, out int blockSize) && blockSize > 0) + { + _blockSizeCache[root] = blockSize; + logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, blockSize); + return blockSize; + } + + logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout); + _blockSizeCache[root] = _defaultBlockSize; + return _defaultBlockSize; + } + catch (Exception ex) + { + logger?.LogTrace(ex, "Error determining block size for {path}", path); + return _defaultBlockSize; + } + } + + [DllImport("kernel32.dll", SetLastError = true, PreserveSig = true)] + private static extern int GetDiskFreeSpaceW([In, MarshalAs(UnmanagedType.LPWStr)] string lpRootPathName, out uint lpSectorsPerCluster, out uint lpBytesPerSector, out uint lpNumberOfFreeClusters, out uint lpTotalNumberOfClusters); + //Extra check on - private static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); + public static bool IsProbablyWine() => Environment.GetEnvironmentVariable("WINELOADERNOEXEC") != null || Environment.GetEnvironmentVariable("WINEDLLPATH") != null || Directory.Exists("/proc/self") && File.Exists("/proc/mounts"); } } -- 2.49.1 From 0af2a6134be75a175c810449320a76f23c0c1869 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 1 Nov 2025 00:09:54 +0100 Subject: [PATCH 019/140] Changed warning text for Brio --- LightlessSync/UI/CharaDataHubUi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/CharaDataHubUi.cs b/LightlessSync/UI/CharaDataHubUi.cs index 9016e6c..51723b9 100644 --- a/LightlessSync/UI/CharaDataHubUi.cs +++ b/LightlessSync/UI/CharaDataHubUi.cs @@ -170,7 +170,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase if (!_charaDataManager.BrioAvailable) { ImGuiHelpers.ScaledDummy(3); - UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters you require to have Brio installed.", ImGuiColors.DalamudRed); + UiSharedService.DrawGroupedCenteredColorText("To utilize any features related to posing or spawning characters, you are required to have Brio installed.", ImGuiColors.DalamudRed); UiSharedService.DistanceSeparator(); } -- 2.49.1 From d4dca455ba8d8abe9323ef4081f9938e4f7f8e98 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 3 Nov 2025 18:54:35 +0100 Subject: [PATCH 020/140] Clean-up, added extra checks on linux in cache monitor, documentation added --- LightlessSync/FileCache/CacheMonitor.cs | 91 ++++-- LightlessSync/FileCache/FileCompactor.cs | 372 ++++++++++++----------- 2 files changed, 260 insertions(+), 203 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 85fb30e..486e11e 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -403,57 +403,94 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase public void RecalculateFileCacheSize(CancellationToken token) { - if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || !Directory.Exists(_configService.Current.CacheFolder)) + if (string.IsNullOrEmpty(_configService.Current.CacheFolder) || + !Directory.Exists(_configService.Current.CacheFolder)) { FileCacheSize = 0; return; } - FileCacheSize = -1; - - var drive = DriveInfo.GetDrives().FirstOrDefault(d => _configService.Current.CacheFolder.StartsWith(d.Name, StringComparison.Ordinal)); - if (drive == null) - { - return; - } + FileCacheSize = -1; + bool isWine = _dalamudUtil?.IsWine ?? false; try { - FileCacheDriveFree = drive.AvailableFreeSpace; + var drive = DriveInfo.GetDrives() + .FirstOrDefault(d => _configService.Current.CacheFolder + .StartsWith(d.Name, StringComparison.OrdinalIgnoreCase)); + + if (drive != null) + FileCacheDriveFree = drive.AvailableFreeSpace; } catch (Exception ex) { - Logger.LogWarning(ex, "Could not determine drive size for Storage Folder {folder}", _configService.Current.CacheFolder); + Logger.LogWarning(ex, "Could not determine drive size for storage folder {folder}", _configService.Current.CacheFolder); } - var files = Directory.EnumerateFiles(_configService.Current.CacheFolder).Select(f => new FileInfo(f)) - .OrderBy(f => f.LastAccessTime).ToList(); - FileCacheSize = files - .Sum(f => - { - token.ThrowIfCancellationRequested(); + var files = Directory.EnumerateFiles(_configService.Current.CacheFolder) + .Select(f => new FileInfo(f)) + .OrderBy(f => f.LastAccessTime) + .ToList(); - try + long totalSize = 0; + + foreach (var f in files) + { + token.ThrowIfCancellationRequested(); + + try + { + long size = 0; + + if (!isWine) { - return _fileCompactor.GetFileSizeOnDisk(f); + try + { + size = _fileCompactor.GetFileSizeOnDisk(f); + } + catch (Exception ex) + { + Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName); + size = f.Length; + } } - catch + else { - return 0; + size = f.Length; } - }); + + totalSize += size; + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Error getting size for {file}", f.FullName); + } + } + + FileCacheSize = totalSize; var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); - - if (FileCacheSize < maxCacheInBytes) return; + if (FileCacheSize < maxCacheInBytes) + return; var maxCacheBuffer = maxCacheInBytes * 0.05d; - while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer) + + while (FileCacheSize > maxCacheInBytes - (long)maxCacheBuffer && files.Count > 0) { var oldestFile = files[0]; - FileCacheSize -= _fileCompactor.GetFileSizeOnDisk(oldestFile); - File.Delete(oldestFile.FullName); - files.Remove(oldestFile); + + try + { + long fileSize = oldestFile.Length; + File.Delete(oldestFile.FullName); + FileCacheSize -= fileSize; + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to delete old file {file}", oldestFile.FullName); + } + + files.RemoveAt(0); } } diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index a917b59..b9c2118 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; using System.Diagnostics; +using System.IO; using System.Runtime.InteropServices; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -14,6 +15,7 @@ public sealed class FileCompactor : IDisposable { public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const ulong WOF_PROVIDER_FILE = 2UL; + public const int _maxRetries = 3; private readonly ConcurrentDictionary _pendingCompactions; private readonly ILogger _logger; @@ -29,6 +31,14 @@ public sealed class FileCompactor : IDisposable Algorithm = (int)CompressionAlgorithm.XPRESS8K, Flags = 0 }; + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + private struct WOF_FILE_COMPRESSION_INFO_V1 + { + public int Algorithm; + public ulong Flags; + } + private enum CompressionAlgorithm { NO_COMPRESSION = -2, @@ -58,6 +68,10 @@ public sealed class FileCompactor : IDisposable public bool MassCompactRunning { get; private set; } public string Progress { get; private set; } = string.Empty; + /// + /// Compact the storage of the Cache Folder + /// + /// Used to check if files needs to be compressed public void CompactStorage(bool compress) { MassCompactRunning = true; @@ -74,6 +88,7 @@ public sealed class FileCompactor : IDisposable try { + // Compress or decompress files if (compress) CompactFile(file); else @@ -135,25 +150,19 @@ public sealed class FileCompactor : IDisposable { try { - var realPath = fileInfo.FullName.Replace("\"", "\\\"", StringComparison.Ordinal); - var psi = new ProcessStartInfo("stat", $"-c %b \"{realPath}\"") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; + bool isWine = _dalamudUtilService?.IsWine ?? false; + string realPath = isWine ? ToLinuxPathIfWine(fileInfo.FullName, isWine) : fileInfo.FullName; - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Could not start stat"); - var outp = proc.StandardOutput.ReadToEnd(); - var err = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); + var fileName = "stat"; + var arguments = $"-c %b \"{realPath}\""; - if (proc.ExitCode != 0) - throw new InvalidOperationException($"stat failed: {err}"); + (bool processControl, bool success) = StartProcessInfo(realPath, fileName, arguments, out Process? proc, out string stdout); - if (!long.TryParse(outp.Trim(), out var blocks)) - throw new InvalidOperationException($"invalid stat output: {outp}"); + if (!processControl && !success) + throw new InvalidOperationException($"stat failed: {proc}"); + + if (!long.TryParse(stdout.Trim(), out var blocks)) + throw new InvalidOperationException($"invalid stat output: {stdout}"); // st_blocks are always 512-byte on Linux enviroment. return blocks * 512L; @@ -287,57 +296,57 @@ public sealed class FileCompactor : IDisposable /// Decompessing state private bool DecompressBtrfsFile(string path) { + var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + try { - var opts = GetMountOptionsForPath(path); - if (opts.Contains("compress", StringComparison.OrdinalIgnoreCase)) + bool isWine = _dalamudUtilService?.IsWine ?? false; + string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; + + var mountOptions = GetMountOptionsForPath(realPath); + if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("Cannot safely decompress {file}: mount options include compression ({opts})", path, opts); + _logger.LogWarning( + "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). " + + "Remount with 'compress=no' before running decompression.", + realPath, mountOptions); return false; } - string realPath = ToLinuxPathIfWine(path, _dalamudUtilService.IsWine); - var psi = new ProcessStartInfo + if (!IsBtrfsCompressedFile(realPath)) { - FileName = _dalamudUtilService.IsWine ? "/bin/bash" : "btrfs", - Arguments = _dalamudUtilService.IsWine - ? $"-c \"btrfs filesystem defragment -- '{EscapeSingle(realPath)}'\"" - : $"filesystem defragment -- \"{realPath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - - using var proc = Process.Start(psi); - if (proc == null) - { - _logger.LogWarning("Failed to start btrfs defragment for {file}", path); - return false; + _logger.LogTrace("File {file} is not compressed, skipping decompression.", realPath); + return true; } - var stdout = proc.StandardOutput.ReadToEnd(); - var stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); + (bool flowControl, bool value) = FileStreamOpening(realPath, ref fs); - if (proc.ExitCode != 0) + if (!flowControl) { - _logger.LogWarning("btrfs defragment failed for {file}: {err}", path, stderr); - return false; + return value; + } + + string fileName = isWine ? "/bin/bash" : "btrfs"; + string command = isWine ? $"-c \"filesystem defragment -- \"{realPath}\"\"" : $"filesystem defragment -- \"{realPath}\""; + + (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); + if (!processControl && !success) + { + return value; } if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs defragment output {file}: {out}", path, stdout.Trim()); + _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); - _logger.LogInformation("Btrfs rewritten uncompressed: {file}", path); + _logger.LogInformation("Decompressed btrfs file successfully: {file}", realPath); + + return true; } catch (Exception ex) { - _logger.LogWarning(ex, "Btrfs decompress error {file}", path); + _logger.LogWarning(ex, "Error rewriting {file} for Btrfs decompression", path); + return false; } - - return true; } /// @@ -379,23 +388,25 @@ public sealed class FileCompactor : IDisposable return true; } - private static string EscapeSingle(string p) => p.Replace("'", "'\\'", StringComparison.Ordinal); - - private static string ToLinuxPathIfWine(string path, bool isWine) + /// + /// Converts to Linux Path if its using Wine (diferent pathing system in Wine) + /// + /// Path that has to be converted + /// Extra check if using the wine enviroment + /// Converted path to be used in Linux + private string ToLinuxPathIfWine(string path, bool isWine) { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return path; - if (!IsProbablyWine() && !isWine) return path; - string realPath = path; + string linuxPath = path; if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - realPath = "/" + path[3..].Replace('\\', '/'); + linuxPath = "/" + path[3..].Replace('\\', '/'); else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - realPath = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/home", path[3..].Replace('\\', '/')).Replace('\\', '/'); + linuxPath = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/home", path[3..].Replace('\\', '/')).Replace('\\', '/'); - return realPath; + _logger.LogTrace("Detected Wine environment. Converted path for compression: {realPath}", linuxPath); + return linuxPath; } /// @@ -414,27 +425,11 @@ public sealed class FileCompactor : IDisposable Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: false); ulong length = (ulong)size; - const int maxRetries = 3; + (bool flowControl, bool value) = FileStreamOpening(path, ref fs); - for (int attempt = 0; attempt < maxRetries; attempt++) + if (!flowControl) { - try - { - fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); - break; - } - catch (IOException) - { - if (attempt == maxRetries - 1) - { - _logger.LogWarning("File still in use after {attempts} attempts, skipping compression for {file}", maxRetries, path); - return false; - } - - int delay = 150 * (attempt + 1); - _logger.LogTrace("File in use, retrying in {delay}ms for {file}", delay, path); - Thread.Sleep(delay); - } + return value; } if (fs == null) @@ -462,14 +457,14 @@ public sealed class FileCompactor : IDisposable return true; } - catch (DllNotFoundException) + catch (DllNotFoundException ex) { - _logger.LogTrace("WofUtil.dll not available; skipping NTFS compaction for {file}", path); + _logger.LogTrace(ex, "WofUtil.dll not available, this DLL is needed for compression; skipping NTFS compaction for {file}", path); return false; } - catch (EntryPointNotFoundException) + catch (EntryPointNotFoundException ex) { - _logger.LogTrace("WOF entrypoint missing (Wine/older OS); skipping NTFS compaction for {file}", path); + _logger.LogTrace(ex, "WOF entrypoint missing (Wine/older OS); skipping NTFS compaction for {file}", path); return false; } catch (Exception ex) @@ -527,37 +522,24 @@ public sealed class FileCompactor : IDisposable bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - var psi = new ProcessStartInfo - { - FileName = isWine ? "/bin/bash" : "filefrag", - Arguments = isWine - ? $"-c \"filefrag -v '{EscapeSingle(realPath)}'\"" - : $"-v \"{realPath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; + var fi = new FileInfo(realPath); - using var proc = Process.Start(psi); - if (proc == null) + if (fi == null) { - _logger.LogWarning("Failed to start filefrag for {file}", path); + _logger.LogWarning("Failed to open {file} for checking on compression; skipping", realPath); return false; } - string stdout = proc.StandardOutput.ReadToEnd(); - string stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); - - if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) + string fileName = isWine ? "/bin/bash" : "filefrag"; + string command = isWine ? $"-c \"filefrag -v '{EscapeSingle(realPath)}'\"" : $"-v \"{realPath}\""; + (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); + if (!processControl && !success) { - _logger.LogTrace("filefrag exited with code {code}: {stderr}", proc.ExitCode, stderr); + return success; } bool compressed = stdout.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); - _logger.LogTrace("Btrfs compression check for {file}: {compressed}", path, compressed); + _logger.LogTrace("Btrfs compression check for {file}: {compressed}", realPath, compressed); return compressed; } catch (Exception ex) @@ -574,89 +556,56 @@ public sealed class FileCompactor : IDisposable /// Compessing state private bool BtrfsCompressFile(string path) { + FileStream? fs = null; + try { bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - if (isWine && IsProbablyWine()) + var fi = new FileInfo(realPath); + + if (fi == null) { - if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = "/" + path[3..].Replace('\\', '/'); - } - else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - { - string linuxHome = Environment.GetEnvironmentVariable("HOME") ?? "/home"; - realPath = Path.Combine(linuxHome, path[3..].Replace('\\', '/')).Replace('\\', '/'); - } - - _logger.LogTrace("Detected Wine environment. Converted path for compression: {realPath}", realPath); - } - - const int maxRetries = 3; - for (int attempt = 0; attempt < maxRetries; attempt++) - { - try - { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - break; - } - catch (IOException) - { - if (attempt == maxRetries - 1) - { - _logger.LogWarning("File still in use after {attempts} attempts; skipping btrfs compression for {file}", maxRetries, path); - return false; - } - - int delay = 150 * (attempt + 1); - _logger.LogTrace("File busy, retrying in {delay}ms for {file}", delay, path); - Thread.Sleep(delay); - } - } - - string command = $"btrfs filesystem defragment -czstd -- \"{realPath}\""; - var psi = new ProcessStartInfo - { - FileName = isWine ? "/bin/bash" : "btrfs", - Arguments = isWine ? $"-c \"{command}\"" : $"filesystem defragment -czstd -- \"{realPath}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - - using var proc = Process.Start(psi); - if (proc == null) - { - _logger.LogWarning("Failed to start btrfs defragment for compression of {file}", path); + _logger.LogWarning("Failed to open {file} for compression; skipping", realPath); return false; } - string stdout = proc.StandardOutput.ReadToEnd(); - string stderr = proc.StandardError.ReadToEnd(); - - try - { - proc.WaitForExit(); - } - catch (Exception ex) + //Skipping small files to make compression a bit faster, its not that effective on small files. + int blockSize = GetBlockSizeForPath(realPath, _logger, isWine); + if (fi.Length < Math.Max(blockSize * 2, 128 * 1024)) { - _logger.LogTrace(ex, "Process.WaitForExit threw under Wine for {file}", path); + _logger.LogTrace("Skipping Btrfs compression for small file {file} ({size} bytes)", realPath, fi.Length); + return true; } - if (proc.ExitCode != 0) + if (IsBtrfsCompressedFile(realPath)) { - _logger.LogWarning("btrfs defragment failed for {file}: {stderr}", path, stderr); - return false; + _logger.LogTrace("File {file} already compressed (Btrfs), skipping file", realPath); + return true; + } + + (bool flowControl, bool value) = FileStreamOpening(realPath, ref fs); + + if (!flowControl) + { + return value; + } + + string fileName = isWine ? "/bin/bash" : "btrfs"; + string command = isWine ? $"-c \"btrfs filesystem defragment -czstd:1 -- \"{realPath}\"\"" : $"btrfs filesystem defragment -czstd:1 -- \"{realPath}\""; + + (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); + if (!processControl && !success) + { + return value; } if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs defragment output for {file}: {stdout}", path, stdout.Trim()); + _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); + + _logger.LogInformation("Compressed btrfs file successfully: {file}", realPath); - _logger.LogInformation("Compressed btrfs file successfully: {file}", path); return true; } catch (Exception ex) @@ -666,6 +615,84 @@ public sealed class FileCompactor : IDisposable } } + + /// + /// Trying opening file stream in certain amount of tries. + /// + /// Path where the file is located + /// Filestream used for the function + /// State of the filestream opening + private (bool flowControl, bool value) FileStreamOpening(string path, ref FileStream? fs) + { + for (int attempt = 0; attempt < _maxRetries; attempt++) + { + try + { + fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + break; + } + catch (IOException) + { + if (attempt == _maxRetries - 1) + { + _logger.LogWarning("File still in use after {attempts} attempts, skipping compression for {file}", _maxRetries, path); + return (flowControl: false, value: false); + } + + int delay = 150 * (attempt + 1); + _logger.LogTrace("File in use, retrying in {delay}ms for {file}", delay, path); + Thread.Sleep(delay); + } + } + + return (flowControl: true, value: default); + } + + /// + /// Starts an process with given Filename and Arguments + /// + /// Path you want to use for the process (Compression is using these) + /// File of the command + /// Arguments used for the command + /// Returns process of the given command + /// Returns output of the given command + /// Returns if the process been done succesfully or not + private (bool processControl, bool success) StartProcessInfo(string path, string fileName, string arguments, out Process? proc, out string stdout) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = "/" + }; + proc = Process.Start(psi); + + if (proc == null) + { + _logger.LogWarning("Failed to start {arguments} for {file}", arguments, path); + stdout = string.Empty; + return (processControl: false, success: false); + } + + stdout = proc.StandardOutput.ReadToEnd(); + string stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + + if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) + { + _logger.LogTrace("{arguments} exited with code {code}: {stderr}", arguments, proc.ExitCode, stderr); + return (processControl: false, success: false); + } + + return (processControl: true, success: default); + } + + private static string EscapeSingle(string p) => p.Replace("'", "'\\'", StringComparison.Ordinal); + private void EnqueueCompaction(string filePath) { if (!_pendingCompactions.TryAdd(filePath, 0)) @@ -735,13 +762,6 @@ public sealed class FileCompactor : IDisposable } } - [StructLayout(LayoutKind.Sequential, Pack = 1)] - private struct WOF_FILE_COMPRESSION_INFO_V1 - { - public int Algorithm; - public ulong Flags; - } - [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); -- 2.49.1 From cfc9f60176ff29bf237427572a7000154fafc72b Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 3 Nov 2025 19:27:47 +0100 Subject: [PATCH 021/140] Added safe checks on enqueue. --- LightlessSync/FileCache/FileCompactor.cs | 152 ++++++++++++++++------- 1 file changed, 107 insertions(+), 45 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index b9c2118..4722b1f 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; using System.Diagnostics; -using System.IO; using System.Runtime.InteropServices; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -140,42 +139,82 @@ public sealed class FileCompactor : IDisposable if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { - var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); - var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); - var size = (long)hosize << 32 | losize; - return ((size + blockSize - 1) / blockSize) * blockSize; + (bool flowControl, long value) = GetFileSizeNTFS(fileInfo); + if (!flowControl) + { + return value; + } } if (fsType == FilesystemType.Btrfs) { - try + (bool flowControl, long value) = GetFileSizeBtrfs(fileInfo); + if (!flowControl) { - bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = isWine ? ToLinuxPathIfWine(fileInfo.FullName, isWine) : fileInfo.FullName; - - var fileName = "stat"; - var arguments = $"-c %b \"{realPath}\""; - - (bool processControl, bool success) = StartProcessInfo(realPath, fileName, arguments, out Process? proc, out string stdout); - - if (!processControl && !success) - throw new InvalidOperationException($"stat failed: {proc}"); - - if (!long.TryParse(stdout.Trim(), out var blocks)) - throw new InvalidOperationException($"invalid stat output: {stdout}"); - - // st_blocks are always 512-byte on Linux enviroment. - return blocks * 512L; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); + return value; } } return fileInfo.Length; } + /// + /// Get File Size in an Btrfs file system (Linux/Wine). + /// + /// File that you want the size from. + /// Succesful check and value of the filesize. + /// Fails on the Process in StartProcessInfo + private (bool flowControl, long value) GetFileSizeBtrfs(FileInfo fileInfo) + { + try + { + bool isWine = _dalamudUtilService?.IsWine ?? false; + string realPath = isWine ? ToLinuxPathIfWine(fileInfo.FullName, isWine) : fileInfo.FullName; + + var fileName = "stat"; + var arguments = $"-c %b \"{realPath}\""; + + (bool processControl, bool success) = StartProcessInfo(realPath, fileName, arguments, out Process? proc, out string stdout); + + if (!processControl && !success) + throw new InvalidOperationException($"stat failed: {proc}"); + + if (!long.TryParse(stdout.Trim(), out var blocks)) + throw new InvalidOperationException($"invalid stat output: {stdout}"); + + // st_blocks are always 512-byte on Linux enviroment. + return (flowControl: false, value: blocks * 512L); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); + } + + return (flowControl: true, value: default); + } + + /// + /// Get File Size in an NTFS file system (Windows). + /// + /// File that you want the size from. + /// Succesful check and value of the filesize. + private (bool flowControl, long value) GetFileSizeNTFS(FileInfo fileInfo) + { + try + { + var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); + var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); + var size = (long)hosize << 32 | losize; + return (flowControl: false, value: ((size + blockSize - 1) / blockSize) * blockSize); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); + } + + return (flowControl: true, value: default); + } + /// /// Compressing the given path with BTRFS or NTFS file system. /// @@ -293,7 +332,7 @@ public sealed class FileCompactor : IDisposable /// Decompress an BTRFS File /// /// Path of the compressed file - /// Decompessing state + /// Decompressing state private bool DecompressBtrfsFile(string path) { var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); @@ -353,7 +392,7 @@ public sealed class FileCompactor : IDisposable /// Decompress an NTFS File /// /// Path of the compressed file - /// Decompessing state + /// Decompressing state private bool DecompressWOFFile(string path, out FileStream fs) { fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); @@ -571,14 +610,6 @@ public sealed class FileCompactor : IDisposable return false; } - //Skipping small files to make compression a bit faster, its not that effective on small files. - int blockSize = GetBlockSizeForPath(realPath, _logger, isWine); - if (fi.Length < Math.Max(blockSize * 2, 128 * 1024)) - { - _logger.LogTrace("Skipping Btrfs compression for small file {file} ({size} bytes)", realPath, fi.Length); - return true; - } - if (IsBtrfsCompressedFile(realPath)) { _logger.LogTrace("File {file} already compressed (Btrfs), skipping file", realPath); @@ -695,21 +726,52 @@ public sealed class FileCompactor : IDisposable private void EnqueueCompaction(string filePath) { + // Safe-checks + if (string.IsNullOrWhiteSpace(filePath)) + return; + + if (!_lightlessConfigService.Current.UseCompactor) + return; + + if (!File.Exists(filePath)) + return; + if (!_pendingCompactions.TryAdd(filePath, 0)) return; - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); - if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs) + bool enqueued = false; + try { - _logger.LogTrace("Skip enqueue (unsupported fs) {fs} {file}", fsType, filePath); - _pendingCompactions.TryRemove(filePath, out _); - return; - } + bool isWine = _dalamudUtilService?.IsWine ?? false; + var fsType = GetFilesystemType(filePath, isWine); - if (!_compactionQueue.Writer.TryWrite(filePath)) + // If under Wine, we should skip NTFS because its not Windows but might return NTFS. + if (fsType == FilesystemType.NTFS && isWine) + { + _logger.LogTrace("Skip enqueue (NTFS under Wine) {file}", filePath); + return; + } + + // Unknown file system should be skipped. + if (fsType != FilesystemType.NTFS && fsType != FilesystemType.Btrfs) + { + _logger.LogTrace("Skip enqueue (unsupported fs) {fs} {file}", fsType, filePath); + return; + } + + if (!_compactionQueue.Writer.TryWrite(filePath)) + { + _logger.LogTrace("Skip enqueue: compaction channel is closed {file}", filePath); + return; + } + + enqueued = true; + _logger.LogTrace("Queued compaction for {file} (fs={fs})", filePath, fsType); + } + finally { - _pendingCompactions.TryRemove(filePath, out _); - _logger.LogDebug("Failed to enqueue compaction {file}", filePath); + if (!enqueued) + _pendingCompactions.TryRemove(filePath, out _); } } -- 2.49.1 From b6aa2bebb18a6bfb3cd882b962b7c1d1a0a34c0d Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 3 Nov 2025 19:59:12 +0100 Subject: [PATCH 022/140] Added more checks. --- LightlessSync/Services/NameplateHandler.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index a28be5f..e302934 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -571,6 +571,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber var nameplateObject = GetNameplateObject(i); return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; } + private HashSet VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; -- 2.49.1 From 1b686e45dc75dc485bce3d81c6d745e76115103c Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 3 Nov 2025 20:19:02 +0100 Subject: [PATCH 023/140] Added more null checks and redid active broadcasting cache. --- LightlessSync/Services/NameplateHandler.cs | 146 +++++++++++---------- 1 file changed, 79 insertions(+), 67 deletions(-) diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index e302934..185a81d 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -15,8 +15,8 @@ using LightlessSync.UtilsEnum.Enum; // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! using Microsoft.Extensions.Logging; +using System.Collections.Immutable; using System.Globalization; -using System.Text; namespace LightlessSync.Services; @@ -32,10 +32,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber private readonly LightlessMediator _mediator; public LightlessMediator Mediator => _mediator; - private bool mEnabled = false; + private bool _mEnabled = false; private bool _needsLabelRefresh = false; - private AddonNamePlate* mpNameplateAddon = null; - private readonly AtkTextNode*[] mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; + private AddonNamePlate* _mpNameplateAddon = null; + private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects]; private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects]; @@ -44,10 +44,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber internal const uint mNameplateNodeIDBase = 0x7D99D500; private const string DefaultLabelText = "LightFinder"; private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; - private const int ContainerOffsetX = 50; + private const int _containerOffsetX = 50; private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); - private volatile HashSet _activeBroadcastingCids = []; + private ImmutableHashSet _activeBroadcastingCids = []; public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager) { @@ -74,17 +74,17 @@ public unsafe class NameplateHandler : IMediatorSubscriber DisableNameplate(); DestroyNameplateNodes(); _mediator.Unsubscribe(this); - mpNameplateAddon = null; + _mpNameplateAddon = null; } internal void EnableNameplate() { - if (!mEnabled) + if (!_mEnabled) { try { _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); - mEnabled = true; + _mEnabled = true; } catch (Exception e) { @@ -96,7 +96,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber internal void DisableNameplate() { - if (mEnabled) + if (_mEnabled) { try { @@ -107,7 +107,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); } - mEnabled = false; + _mEnabled = false; HideAllNameplateNodes(); } } @@ -116,15 +116,15 @@ public unsafe class NameplateHandler : IMediatorSubscriber { var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; - if (mpNameplateAddon != pNameplateAddon) + if (_mpNameplateAddon != pNameplateAddon) { - for (int i = 0; i < mTextNodes.Length; ++i) mTextNodes[i] = null; + for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null; System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); - mpNameplateAddon = pNameplateAddon; - if (mpNameplateAddon != null) CreateNameplateNodes(); + _mpNameplateAddon = pNameplateAddon; + if (_mpNameplateAddon != null) CreateNameplateNodes(); } UpdateNameplateNodes(); @@ -139,6 +139,11 @@ public unsafe class NameplateHandler : IMediatorSubscriber continue; var pNameplateResNode = nameplateObject.Value.NameContainer; + if (pNameplateResNode == null) + continue; + if (pNameplateResNode->ChildNode == null) + continue; + var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare); if (pNewNode != null) @@ -150,7 +155,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); pNewNode->AtkResNode.SetUseDepthBasedPriority(true); - mTextNodes[i] = pNewNode; + _mTextNodes[i] = pNewNode; } } } @@ -158,12 +163,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void DestroyNameplateNodes() { var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; - if (mpNameplateAddon == null || mpNameplateAddon != pCurrentNameplateAddon) + if (_mpNameplateAddon == null || _mpNameplateAddon != pCurrentNameplateAddon) return; for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { - var pTextNode = mTextNodes[i]; + var pTextNode = _mTextNodes[i]; var pNameplateNode = GetNameplateComponentNode(i); if (pTextNode != null && pNameplateNode != null) { @@ -175,7 +180,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; pNameplateNode->Component->UldManager.UpdateDrawNodeList(); pTextNode->AtkResNode.Destroy(true); - mTextNodes[i] = null; + _mTextNodes[i] = null; } catch (Exception e) { @@ -192,7 +197,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void HideAllNameplateNodes() { - for (int i = 0; i < mTextNodes.Length; ++i) + for (int i = 0; i < _mTextNodes.Length; ++i) { HideNameplateTextNode(i); } @@ -200,22 +205,34 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void UpdateNameplateNodes() { - var framework = Framework.Instance(); - var ui3DModule = framework->GetUIModule()->GetUI3DModule(); + var currentAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; + if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) + return; + var framework = Framework.Instance(); + + var ui3DModule = framework->GetUIModule()->GetUI3DModule(); if (ui3DModule == null) return; - for (int i = 0; i < ui3DModule->NamePlateObjectInfoCount; ++i) + var vec = ui3DModule->NamePlateObjectInfoPointers; + if (vec.IsEmpty) + return; + + var safeCount = System.Math.Min( + ui3DModule->NamePlateObjectInfoCount, + vec.Length + ); + + for (int i = 0; i < safeCount; ++i) { - if (ui3DModule->NamePlateObjectInfoPointers.IsEmpty) continue; + var config = _configService.Current; - var objectInfoPtr = ui3DModule->NamePlateObjectInfoPointers[i]; - - if (objectInfoPtr == null) continue; + var objectInfoPtr = vec[i]; + if (objectInfoPtr == null) + continue; var objectInfo = objectInfoPtr.Value; - if (objectInfo == null || objectInfo->GameObject == null) continue; @@ -223,62 +240,61 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) continue; - var pNode = mTextNodes[nameplateIndex]; + var pNode = _mTextNodes[nameplateIndex]; if (pNode == null) continue; - if (mpNameplateAddon == null) - continue; - + // CID gating var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); - if (cid == null || !_activeBroadcastingCids.Contains(cid)) { pNode->AtkResNode.ToggleVisibility(false); continue; } - if (!_configService.Current.LightfinderLabelShowOwn && (objectInfo->GameObject->GetGameObjectId() == _clientState.LocalPlayer.GameObjectId)) + var local = _clientState.LocalPlayer; + if (!config.LightfinderLabelShowOwn && local != null && + objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) { pNode->AtkResNode.ToggleVisibility(false); continue; } - if (!_configService.Current.LightfinderLabelShowPaired && VisibleUserIds.Any(u => u == objectInfo->GameObject->GetGameObjectId())) - { - pNode->AtkResNode.ToggleVisibility(false); - continue; - } - - var nameplateObject = mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; - nameplateObject.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); - - var pNameplateIconNode = nameplateObject.MarkerIcon; - var pNameplateResNode = nameplateObject.NameContainer; - var pNameplateTextNode = nameplateObject.NameText; - bool IsVisible = pNameplateIconNode->AtkResNode.IsVisible() || (pNameplateResNode->IsVisible() && pNameplateTextNode->AtkResNode.IsVisible()) || _configService.Current.LightfinderLabelShowHidden; - pNode->AtkResNode.ToggleVisibility(IsVisible); - - if (nameplateObject.RootComponentNode == null || - nameplateObject.NameContainer == null || - nameplateObject.NameText == null) + var visibleUserIds = VisibleUserIds; + var hidePaired = !config.LightfinderLabelShowPaired; + + var goId = (ulong)objectInfo->GameObject->GetGameObjectId(); + if (hidePaired && visibleUserIds.Contains(goId)) { pNode->AtkResNode.ToggleVisibility(false); continue; } + var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; + var root = nameplateObject.RootComponentNode; var nameContainer = nameplateObject.NameContainer; var nameText = nameplateObject.NameText; + var marker = nameplateObject.MarkerIcon; - if (nameContainer == null || nameText == null) + if (root == null || nameContainer == null || nameText == null) { pNode->AtkResNode.ToggleVisibility(false); continue; } + + root->Component->UldManager.UpdateDrawNodeList(); + + bool isVisible = + ((marker != null) && marker->AtkResNode.IsVisible()) || + (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || + config.LightfinderLabelShowHidden; + + pNode->AtkResNode.ToggleVisibility(isVisible); + if (!isVisible) + continue; var labelColor = UIColors.Get("Lightfinder"); var edgeColor = UIColors.Get("LightfinderEdge"); - var config = _configService.Current; var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; @@ -545,7 +561,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber } private void HideNameplateTextNode(int i) { - var pNode = mTextNodes[i]; + var pNode = _mTextNodes[i]; if (pNode != null) { pNode->AtkResNode.ToggleVisibility(false); @@ -555,10 +571,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) { if (i < AddonNamePlate.NumNamePlateObjects && - mpNameplateAddon != null && - mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) + _mpNameplateAddon != null && + _mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) { - return mpNameplateAddon->NamePlateObjectArray[i]; + return _mpNameplateAddon->NamePlateObjectArray[i]; } else { @@ -576,6 +592,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; + public void FlagRefresh() { _needsLabelRefresh = true; @@ -592,18 +609,13 @@ public unsafe class NameplateHandler : IMediatorSubscriber public void UpdateBroadcastingCids(IEnumerable cids) { - var newSet = cids.ToHashSet(); - - var changed = !_activeBroadcastingCids.SetEquals(newSet); - if (!changed) + var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); + if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) return; - _activeBroadcastingCids.Clear(); - foreach (var cid in newSet) - _activeBroadcastingCids.Add(cid); - + // single atomic swap readers always see a consistent snapshot + _activeBroadcastingCids = newSet; _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids)); - FlagRefresh(); } -- 2.49.1 From 35636f27f6b8b38e6864b6f8d4a6e8e09a857680 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 3 Nov 2025 21:47:15 +0100 Subject: [PATCH 024/140] Cleanup --- LightlessSync/Services/NameplateHandler.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index 185a81d..5e83683 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -205,7 +205,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void UpdateNameplateNodes() { - var currentAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; + var currentAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate").Address; if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) return; @@ -248,7 +248,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) { - pNode->AtkResNode.ToggleVisibility(false); + pNode->AtkResNode.ToggleVisibility(enable: false); continue; } @@ -256,7 +256,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (!config.LightfinderLabelShowOwn && local != null && objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) { - pNode->AtkResNode.ToggleVisibility(false); + pNode->AtkResNode.ToggleVisibility(enable: false); continue; } @@ -266,7 +266,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber var goId = (ulong)objectInfo->GameObject->GetGameObjectId(); if (hidePaired && visibleUserIds.Contains(goId)) { - pNode->AtkResNode.ToggleVisibility(false); + pNode->AtkResNode.ToggleVisibility(enable: false); continue; } @@ -278,7 +278,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (root == null || nameContainer == null || nameText == null) { - pNode->AtkResNode.ToggleVisibility(false); + pNode->AtkResNode.ToggleVisibility(enable: false); continue; } @@ -453,7 +453,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber positionY += config.LightfinderLabelOffsetY; alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); - pNode->AtkResNode.SetUseDepthBasedPriority(true); + pNode->AtkResNode.SetUseDepthBasedPriority(enable: true); pNode->AtkResNode.Color.A = 255; @@ -613,9 +613,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) return; - // single atomic swap readers always see a consistent snapshot _activeBroadcastingCids = newSet; - _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(",", _activeBroadcastingCids)); + _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); FlagRefresh(); } -- 2.49.1 From 1d672d25520f4aee9b6c2b88ee486d82e821967a Mon Sep 17 00:00:00 2001 From: azyges <229218900+azyges@users.noreply.github.com> Date: Thu, 6 Nov 2025 23:40:58 +0900 Subject: [PATCH 025/140] improve checks and add logging --- LightlessSync/Services/NameplateHandler.cs | 94 ++++++++++++++++++---- 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index 5e83683..11af974 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -1,5 +1,6 @@ using Dalamud.Game.Addon.Lifecycle; using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.Text; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.System.Framework; @@ -15,6 +16,7 @@ using LightlessSync.UtilsEnum.Enum; // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! using Microsoft.Extensions.Logging; +using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; @@ -114,6 +116,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void NameplateDrawDetour(AddonEvent type, AddonArgs args) { + if (args.Addon.Address == nint.Zero) + { + _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); + return; + } + var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; if (_mpNameplateAddon != pNameplateAddon) @@ -138,6 +146,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (nameplateObject == null) continue; + var rootNode = nameplateObject.Value.RootComponentNode; + if (rootNode == null || rootNode->Component == null) + continue; + var pNameplateResNode = nameplateObject.Value.NameContainer; if (pNameplateResNode == null) continue; @@ -153,7 +165,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pNewNode->AtkResNode.NextSiblingNode = pLastChild; pNewNode->AtkResNode.ParentNode = pNameplateResNode; pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; - nameplateObject.Value.RootComponentNode->Component->UldManager.UpdateDrawNodeList(); + rootNode->Component->UldManager.UpdateDrawNodeList(); pNewNode->AtkResNode.SetUseDepthBasedPriority(true); _mTextNodes[i] = pNewNode; } @@ -162,15 +174,34 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void DestroyNameplateNodes() { - var pCurrentNameplateAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate", 1).Address; - if (_mpNameplateAddon == null || _mpNameplateAddon != pCurrentNameplateAddon) + var currentHandle = _gameGui.GetAddonByName("NamePlate", 1); + if (currentHandle.Address == nint.Zero) + { + _logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available."); return; + } + + var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address; + if (_mpNameplateAddon == null) + return; + + if (_mpNameplateAddon != pCurrentNameplateAddon) + { + _logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon); + return; + } for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) { var pTextNode = _mTextNodes[i]; var pNameplateNode = GetNameplateComponentNode(i); - if (pTextNode != null && pNameplateNode != null) + if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null)) + { + _logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i); + continue; + } + + if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null) { try { @@ -205,20 +236,48 @@ public unsafe class NameplateHandler : IMediatorSubscriber private void UpdateNameplateNodes() { - var currentAddon = (AddonNamePlate*)_gameGui.GetAddonByName("NamePlate").Address; - if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) + var currentHandle = _gameGui.GetAddonByName("NamePlate"); + if (currentHandle.Address == nint.Zero) + { + _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); return; + } + + var currentAddon = (AddonNamePlate*)currentHandle.Address; + if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) + { + if (_mpNameplateAddon != null) + _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); + return; + } var framework = Framework.Instance(); - - var ui3DModule = framework->GetUIModule()->GetUI3DModule(); - if (ui3DModule == null) + if (framework == null) + { + _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); return; + } + + var uiModule = framework->GetUIModule(); + if (uiModule == null) + { + _logger.LogDebug("UI module unavailable during nameplate update, skipping."); + return; + } + + var ui3DModule = uiModule->GetUI3DModule(); + if (ui3DModule == null) + { + _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); + return; + } var vec = ui3DModule->NamePlateObjectInfoPointers; if (vec.IsEmpty) return; + var visibleUserIdsSnapshot = VisibleUserIds; + var safeCount = System.Math.Min( ui3DModule->NamePlateObjectInfoCount, vec.Length @@ -244,8 +303,15 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (pNode == null) continue; + var gameObject = objectInfo->GameObject; + if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player) + { + pNode->AtkResNode.ToggleVisibility(enable: false); + continue; + } + // CID gating - var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)objectInfo->GameObject); + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); if (cid == null || !_activeBroadcastingCids.Contains(cid)) { pNode->AtkResNode.ToggleVisibility(enable: false); @@ -260,11 +326,10 @@ public unsafe class NameplateHandler : IMediatorSubscriber continue; } - var visibleUserIds = VisibleUserIds; var hidePaired = !config.LightfinderLabelShowPaired; - var goId = (ulong)objectInfo->GameObject->GetGameObjectId(); - if (hidePaired && visibleUserIds.Contains(goId)) + var goId = (ulong)gameObject->GetGameObjectId(); + if (hidePaired && visibleUserIdsSnapshot.Contains(goId)) { pNode->AtkResNode.ToggleVisibility(enable: false); continue; @@ -276,8 +341,9 @@ public unsafe class NameplateHandler : IMediatorSubscriber var nameText = nameplateObject.NameText; var marker = nameplateObject.MarkerIcon; - if (root == null || nameContainer == null || nameText == null) + if (root == null || root->Component == null || nameContainer == null || nameText == null) { + _logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex); pNode->AtkResNode.ToggleVisibility(enable: false); continue; } -- 2.49.1 From 557121a9b7461e71c02fa345ac1f5362fabb2ac6 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 7 Nov 2025 05:27:58 +0100 Subject: [PATCH 026/140] Added batching for the File Frag command for the iscompressed calls. --- LightlessSync/FileCache/FileCompactor.cs | 491 +++++++++++------- .../Compression/BatchFileFragService.cs | 245 +++++++++ 2 files changed, 546 insertions(+), 190 deletions(-) create mode 100644 LightlessSync/Services/Compression/BatchFileFragService.cs diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 4722b1f..e6505b9 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,5 +1,6 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; +using LightlessSync.Services.Compression; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; @@ -23,8 +24,12 @@ public sealed class FileCompactor : IDisposable private readonly Channel _compactionQueue; private readonly CancellationTokenSource _compactionCts = new(); - private readonly Task _compactionWorker; - + + private readonly List _workers = []; + private readonly SemaphoreSlim _globalGate; + private static readonly SemaphoreSlim _btrfsGate = new(4, 4); + private readonly BatchFilefragService _fragBatch; + private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo = new() { Algorithm = (int)CompressionAlgorithm.XPRESS8K, @@ -57,11 +62,30 @@ public sealed class FileCompactor : IDisposable _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions { - SingleReader = true, + SingleReader = false, SingleWriter = false }); - _compactionWorker = Task.Factory.StartNew(() => ProcessQueueAsync(_compactionCts.Token), _compactionCts.Token, TaskCreationOptions.LongRunning,TaskScheduler.Default).Unwrap(); + int workers = Math.Clamp(Math.Min(Environment.ProcessorCount / 2, 4), 1, 8); + _globalGate = new SemaphoreSlim(workers, workers); + int workerCount = Math.Max(workers * 2, workers); + + for (int i = 0; i < workerCount; i++) + { + _workers.Add(Task.Factory.StartNew( + () => ProcessQueueWorkerAsync(_compactionCts.Token), + _compactionCts.Token, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap()); + } + + _fragBatch = new BatchFilefragService( + useShell: _dalamudUtilService.IsWine, + log: _logger, + batchSize: 128, + flushMs: 25); + + _logger.LogInformation("FileCompactor started with {workers} workers", workerCount); } public bool MassCompactRunning { get; private set; } @@ -171,18 +195,12 @@ public sealed class FileCompactor : IDisposable bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = isWine ? ToLinuxPathIfWine(fileInfo.FullName, isWine) : fileInfo.FullName; - var fileName = "stat"; - var arguments = $"-c %b \"{realPath}\""; + (bool ok, string stdout, string stderr, int code) = + RunProcessDirect("stat", ["-c", "%b", realPath]); - (bool processControl, bool success) = StartProcessInfo(realPath, fileName, arguments, out Process? proc, out string stdout); + if (!ok || !long.TryParse(stdout.Trim(), out var blocks)) + throw new InvalidOperationException($"stat failed (exit {code}): {stderr}"); - if (!processControl && !success) - throw new InvalidOperationException($"stat failed: {proc}"); - - if (!long.TryParse(stdout.Trim(), out var blocks)) - throw new InvalidOperationException($"invalid stat output: {stdout}"); - - // st_blocks are always 512-byte on Linux enviroment. return (flowControl: false, value: blocks * 512L); } catch (Exception ex) @@ -224,18 +242,22 @@ public sealed class FileCompactor : IDisposable var fi = new FileInfo(filePath); if (!fi.Exists) { - _logger.LogTrace("Skip compact: missing {file}", filePath); + _logger.LogTrace("Skip compaction: missing {file}", filePath); return; } var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); - _logger.LogTrace("Detected filesystem {fs} for {file} (isWine={wine})", fsType, filePath, _dalamudUtilService.IsWine); var oldSize = fi.Length; - int blockSize = GetBlockSizeForPath(fi.FullName, _logger, _dalamudUtilService.IsWine); - if (oldSize < Math.Max(blockSize, 8 * 1024)) + + // We skipping small files (128KiB) as they slow down the system a lot for BTRFS. as BTRFS has a different blocksize it requires an different calculation. + long minSizeBytes = fsType == FilesystemType.Btrfs + ? Math.Max(blockSize * 2L, 128 * 1024L) + : Math.Max(blockSize, 8 * 1024L); + + if (oldSize < minSizeBytes) { - _logger.LogTrace("Skip compact: {file} < block {block}", filePath, blockSize); + _logger.LogTrace("Skip compaction: {file} ({size} B) < threshold ({th} B)", filePath, oldSize, minSizeBytes); return; } @@ -243,7 +265,7 @@ public sealed class FileCompactor : IDisposable { if (!IsWOFCompactedFile(filePath)) { - _logger.LogDebug("NTFS compact XPRESS8K: {file}", filePath); + _logger.LogDebug("NTFS compaction XPRESS8K: {file}", filePath); if (WOFCompressFile(filePath)) { var newSize = GetFileSizeOnDisk(fi); @@ -265,7 +287,7 @@ public sealed class FileCompactor : IDisposable { if (!IsBtrfsCompressedFile(filePath)) { - _logger.LogDebug("Btrfs compress zstd: {file}", filePath); + _logger.LogDebug("Btrfs compression zstd: {file}", filePath); if (BtrfsCompressFile(filePath)) { var newSize = GetFileSizeOnDisk(fi); @@ -299,7 +321,7 @@ public sealed class FileCompactor : IDisposable { try { - bool flowControl = DecompressWOFFile(path, out FileStream fs); + bool flowControl = DecompressWOFFile(path); if (!flowControl) { return; @@ -335,10 +357,9 @@ public sealed class FileCompactor : IDisposable /// Decompressing state private bool DecompressBtrfsFile(string path) { - var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); - try { + _btrfsGate.Wait(_compactionCts.Token); bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; @@ -358,27 +379,25 @@ public sealed class FileCompactor : IDisposable return true; } - (bool flowControl, bool value) = FileStreamOpening(realPath, ref fs); + if (!ProbeFileReadable(realPath)) + return false; - if (!flowControl) + (bool ok, string stdout, string stderr, int code) = + isWine + ? RunProcessShell($"btrfs filesystem defragment -- {QuoteSingle(realPath)}") + : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", realPath]); + + if (!ok) { - return value; - } - - string fileName = isWine ? "/bin/bash" : "btrfs"; - string command = isWine ? $"-c \"filesystem defragment -- \"{realPath}\"\"" : $"filesystem defragment -- \"{realPath}\""; - - (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); - if (!processControl && !success) - { - return value; + _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {stderr}", + realPath, code, stderr); + return false; } if (!string.IsNullOrWhiteSpace(stdout)) _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); - _logger.LogInformation("Decompressed btrfs file successfully: {file}", realPath); - + _logger.LogInformation("Decompressed (rewritten) Btrfs file: {file}", realPath); return true; } catch (Exception ex) @@ -386,6 +405,11 @@ public sealed class FileCompactor : IDisposable _logger.LogWarning(ex, "Error rewriting {file} for Btrfs decompression", path); return false; } + finally + { + if (_btrfsGate.CurrentCount < 4) + _btrfsGate.Release(); + } } /// @@ -393,38 +417,40 @@ public sealed class FileCompactor : IDisposable /// /// Path of the compressed file /// Decompressing state - private bool DecompressWOFFile(string path, out FileStream fs) + private bool DecompressWOFFile(string path) { - fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); - var handle = fs.SafeFileHandle; - - if (handle.IsInvalid) + if (TryIsWofExternal(path, out bool isExternal, out int algo)) { - _logger.LogWarning("Invalid handle: {file}", path); - return false; + if (!isExternal) + { + _logger.LogTrace("Already decompressed file: {file}", path); + return true; + } + var compressString = ((CompressionAlgorithm)algo).ToString(); + _logger.LogTrace("WOF compression (algo={algo}) detected for {file}", compressString, path); } - if (!DeviceIoControl(handle, FSCTL_DELETE_EXTERNAL_BACKING, - IntPtr.Zero, 0, IntPtr.Zero, 0, - out _, IntPtr.Zero)) + return WithFileHandleForWOF(path, FileAccess.ReadWrite, h => { - int err = Marshal.GetLastWin32Error(); + if (!DeviceIoControl(h, FSCTL_DELETE_EXTERNAL_BACKING, + IntPtr.Zero, 0, IntPtr.Zero, 0, + out uint _, IntPtr.Zero)) + { + int err = Marshal.GetLastWin32Error(); + // 342 error code means its been decompressed after the control, we handle it as it succesfully been decompressed. + if (err == 342) + { + _logger.LogTrace("Successfully decompressed NTFS file {file}", path); + return true; + } - if (err == 342) - { - _logger.LogTrace("File {file} not externally backed (already decompressed)", path); - } - else - { _logger.LogWarning("DeviceIoControl failed for {file} with Win32 error {err}", path, err); + return false; } - } - else - { - _logger.LogTrace("Successfully decompressed NTFS file {file}", path); - } - return true; + _logger.LogTrace("Successfully decompressed NTFS file {file}", path); + return true; + }); } /// @@ -455,7 +481,6 @@ public sealed class FileCompactor : IDisposable /// Compessing state private bool WOFCompressFile(string path) { - FileStream? fs = null; int size = Marshal.SizeOf(); IntPtr efInfoPtr = Marshal.AllocHGlobal(size); @@ -464,46 +489,28 @@ public sealed class FileCompactor : IDisposable Marshal.StructureToPtr(_efInfo, efInfoPtr, fDeleteOld: false); ulong length = (ulong)size; - (bool flowControl, bool value) = FileStreamOpening(path, ref fs); - - if (!flowControl) + return WithFileHandleForWOF(path, FileAccess.ReadWrite, h => { - return value; - } + int ret = WofSetFileDataLocation(h, WOF_PROVIDER_FILE, efInfoPtr, length); - if (fs == null) - { - _logger.LogWarning("Failed to open {file} for compression; skipping", path); - return false; - } + // 0x80070158 is the benign "already compressed/unsupported" style return + if (ret != 0 && ret != unchecked((int)0x80070158)) + { + _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); + return false; + } - var handle = fs.SafeFileHandle; - - if (handle.IsInvalid) - { - _logger.LogWarning("Invalid file handle for {file}", path); - return false; - } - - int ret = WofSetFileDataLocation(handle, WOF_PROVIDER_FILE, efInfoPtr, length); - - // 0x80070158 is WOF error whenever compression fails in an non-fatal way. - if (ret != 0 && ret != unchecked((int)0x80070158)) - { - _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); - return false; - } - - return true; + return true; + }); } catch (DllNotFoundException ex) { - _logger.LogTrace(ex, "WofUtil.dll not available, this DLL is needed for compression; skipping NTFS compaction for {file}", path); + _logger.LogTrace(ex, "WofUtil not available; skipping NTFS compaction for {file}", path); return false; } catch (EntryPointNotFoundException ex) { - _logger.LogTrace(ex, "WOF entrypoint missing (Wine/older OS); skipping NTFS compaction for {file}", path); + _logger.LogTrace(ex, "WOF entrypoint missing on this system (Wine/older OS); skipping NTFS compaction for {file}", path); return false; } catch (Exception ex) @@ -513,12 +520,8 @@ public sealed class FileCompactor : IDisposable } finally { - fs?.Dispose(); - if (efInfoPtr != IntPtr.Zero) - { Marshal.FreeHGlobal(efInfoPtr); - } } } @@ -549,6 +552,36 @@ public sealed class FileCompactor : IDisposable } } + /// + /// Checks if an File is compacted any WOF compression with an WOF backing + /// + /// Path of the file + /// State of the file, if its an external (no backing) and which algorithm if detected + private static bool TryIsWofExternal(string path, out bool isExternal, out int algorithm) + { + isExternal = false; + algorithm = 0; + try + { + uint buf = (uint)Marshal.SizeOf(); + int hr = WofIsExternalFile(path, out int ext, out _, out var info, ref buf); + if (hr == 0 && ext != 0) + { + isExternal = true; + algorithm = info.Algorithm; + } + return true; + } + catch (DllNotFoundException) + { + return false; + } + catch (EntryPointNotFoundException) + { + return false; + } + } + /// /// Checks if an File is compacted with Btrfs compression /// @@ -558,34 +591,23 @@ public sealed class FileCompactor : IDisposable { try { + _btrfsGate.Wait(_compactionCts.Token); + bool isWine = _dalamudUtilService?.IsWine ?? false; string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - var fi = new FileInfo(realPath); - - if (fi == null) - { - _logger.LogWarning("Failed to open {file} for checking on compression; skipping", realPath); - return false; - } - - string fileName = isWine ? "/bin/bash" : "filefrag"; - string command = isWine ? $"-c \"filefrag -v '{EscapeSingle(realPath)}'\"" : $"-v \"{realPath}\""; - (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); - if (!processControl && !success) - { - return success; - } - - bool compressed = stdout.Contains("flags: compressed", StringComparison.OrdinalIgnoreCase); - _logger.LogTrace("Btrfs compression check for {file}: {compressed}", realPath, compressed); - return compressed; + return _fragBatch.IsCompressedAsync(realPath, _compactionCts.Token).GetAwaiter().GetResult(); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to detect Btrfs compression for {file}", path); return false; } + finally + { + if (_btrfsGate.CurrentCount < 4) + _btrfsGate.Release(); + } } /// @@ -595,8 +617,6 @@ public sealed class FileCompactor : IDisposable /// Compessing state private bool BtrfsCompressFile(string path) { - FileStream? fs = null; - try { bool isWine = _dalamudUtilService?.IsWine ?? false; @@ -616,21 +636,22 @@ public sealed class FileCompactor : IDisposable return true; } - (bool flowControl, bool value) = FileStreamOpening(realPath, ref fs); + if (!ProbeFileReadable(realPath)) + return false; - if (!flowControl) + (bool ok, string stdout, string stderr, int code) = + isWine + ? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(realPath)}") + : RunProcessDirect("btrfs", ["filesystem", "defragment", "-clzo", "--", realPath]); + + if (!ok) { - return value; + _logger.LogWarning("btrfs defragment failed for {file} (exit {code}): {stderr}", realPath, code, stderr); + return false; } - string fileName = isWine ? "/bin/bash" : "btrfs"; - string command = isWine ? $"-c \"btrfs filesystem defragment -czstd:1 -- \"{realPath}\"\"" : $"btrfs filesystem defragment -czstd:1 -- \"{realPath}\""; - - (bool processControl, bool success) = StartProcessInfo(realPath, fileName, command, out Process? proc, out string stdout); - if (!processControl && !success) - { - return value; - } + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogTrace("btrfs output for {file}: {stdout}", realPath, stdout.Trim()); if (!string.IsNullOrWhiteSpace(stdout)) _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); @@ -648,82 +669,171 @@ public sealed class FileCompactor : IDisposable /// - /// Trying opening file stream in certain amount of tries. + /// Probe file if its readable for certain amount of tries. /// /// Path where the file is located /// Filestream used for the function /// State of the filestream opening - private (bool flowControl, bool value) FileStreamOpening(string path, ref FileStream? fs) + private bool ProbeFileReadable(string path) { for (int attempt = 0; attempt < _maxRetries; attempt++) { try { - fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); - break; + using var _ = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return true; } - catch (IOException) + catch (IOException ex) { if (attempt == _maxRetries - 1) { - _logger.LogWarning("File still in use after {attempts} attempts, skipping compression for {file}", _maxRetries, path); - return (flowControl: false, value: false); + _logger.LogWarning(ex, "File still in use after {attempts} attempts, skipping {file}", _maxRetries, path); + return false; + } + int delay = 150 * (attempt + 1); + _logger.LogTrace(ex, "File busy, retrying in {delay}ms for {file}", delay, path); + Thread.Sleep(delay); + } + } + return false; + } + + /// + /// Attempt opening file stream for WOF functions + /// + /// File that has to be accessed + /// Permissions for the file + /// Access of the file stream for the WOF function to handle. + /// State of the attempt for the file + private bool WithFileHandleForWOF(string path, FileAccess access, Func body) + { + const FileShare share = FileShare.ReadWrite | FileShare.Delete; + + for (int attempt = 0; attempt < _maxRetries; attempt++) + { + try + { + using var fs = new FileStream(path, FileMode.Open, access, share); + + var handle = fs.SafeFileHandle; + if (handle.IsInvalid) + { + _logger.LogWarning("Invalid file handle for {file}", path); + return false; + } + + return body(handle); + } + catch (IOException ex) + { + if (attempt == _maxRetries - 1) + { + _logger.LogWarning(ex, "File still in use after {attempts} attempts, skipping {file}", _maxRetries, path); + return false; } int delay = 150 * (attempt + 1); - _logger.LogTrace("File in use, retrying in {delay}ms for {file}", delay, path); + _logger.LogTrace(ex, "File busy, retrying in {delay}ms for {file}", delay, path); Thread.Sleep(delay); } } - return (flowControl: true, value: default); + return false; } /// - /// Starts an process with given Filename and Arguments + /// Runs an nonshell process meant for Linux/Wine enviroments /// - /// Path you want to use for the process (Compression is using these) - /// File of the command - /// Arguments used for the command - /// Returns process of the given command - /// Returns output of the given command - /// Returns if the process been done succesfully or not - private (bool processControl, bool success) StartProcessInfo(string path, string fileName, string arguments, out Process? proc, out string stdout) + /// File that has to be excuted + /// Arguments meant for the file/command + /// Working directory used to execute the file with/without arguments + /// Timeout timer for the process + /// State of the process, output of the process and error with exit code + private (bool ok, string stdout, string stderr, int exitCode) RunProcessDirect(string fileName, IEnumerable args, string? workingDir = null, int timeoutMs = 60000) { - var psi = new ProcessStartInfo + var psi = new ProcessStartInfo(fileName) { - FileName = fileName, - Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" + CreateNoWindow = true }; - proc = Process.Start(psi); + if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; - if (proc == null) + foreach (var a in args) psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi); + if (proc is null) return (false, "", "failed to start process", -1); + + var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); + var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); + + if (!proc.WaitForExit(timeoutMs)) { - _logger.LogWarning("Failed to start {arguments} for {file}", arguments, path); - stdout = string.Empty; - return (processControl: false, success: false); + try + { + proc.Kill(entireProcessTree: true); + } + catch + { + // Ignore this catch on the dispose + } + + Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); + return (false, outTask.Result, "timeout", -1); } - stdout = proc.StandardOutput.ReadToEnd(); - string stderr = proc.StandardError.ReadToEnd(); - proc.WaitForExit(); - - if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) - { - _logger.LogTrace("{arguments} exited with code {code}: {stderr}", arguments, proc.ExitCode, stderr); - return (processControl: false, success: false); - } - - return (processControl: true, success: default); + Task.WaitAll(outTask, errTask); + return (proc.ExitCode == 0, outTask.Result, errTask.Result, proc.ExitCode); } - private static string EscapeSingle(string p) => p.Replace("'", "'\\'", StringComparison.Ordinal); + /// + /// Runs an shell using '/bin/bash'/ command meant for Linux/Wine enviroments + /// + /// Command that has to be excuted + /// Timeout timer for the process + /// State of the process, output of the process and error with exit code + private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, int timeoutMs = 60000) + { + var psi = new ProcessStartInfo("/bin/bash") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-c"); + psi.ArgumentList.Add(command); + using var proc = Process.Start(psi); + if (proc is null) return (false, "", "failed to start /bin/bash", -1); + + var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); + var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); + + if (!proc.WaitForExit(timeoutMs)) + { + try + { + proc.Kill(entireProcessTree: true); + } + catch + { + // Ignore this catch on the dispose + } + + Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); + return (false, outTask.Result, "timeout", -1); + } + + Task.WaitAll(outTask, errTask); + return (proc.ExitCode == 0, outTask.Result, errTask.Result, proc.ExitCode); + } + + /// + /// Enqueues the compaction/decompation of an filepath. + /// + /// Filepath that will be enqueued private void EnqueueCompaction(string filePath) { // Safe-checks @@ -759,9 +869,10 @@ public sealed class FileCompactor : IDisposable return; } + // Channel got closed, skip enqueue on file if (!_compactionQueue.Writer.TryWrite(filePath)) { - _logger.LogTrace("Skip enqueue: compaction channel is closed {file}", filePath); + _logger.LogTrace("Skip enqueue: compaction channel is/got closed {file}", filePath); return; } @@ -775,7 +886,11 @@ public sealed class FileCompactor : IDisposable } } - private async Task ProcessQueueAsync(CancellationToken token) + /// + /// Process the queue with, meant for a worker/thread + /// + /// Cancellation token for the worker whenever it needs to be stopped + private async Task ProcessQueueWorkerAsync(CancellationToken token) { try { @@ -785,28 +900,20 @@ public sealed class FileCompactor : IDisposable { try { - if (token.IsCancellationRequested) - { - return; - } + token.ThrowIfCancellationRequested(); + await _globalGate.WaitAsync(token).ConfigureAwait(false); - if (!_lightlessConfigService.Current.UseCompactor) + try { - continue; + if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath)) + CompactFile(filePath); } - - if (!File.Exists(filePath)) + finally { - _logger.LogTrace("Skip compact (missing) {file}", filePath); - continue; + _globalGate.Release(); } - - CompactFile(filePath); - } - catch (OperationCanceledException) - { - return; } + catch (OperationCanceledException) { return; } catch (Exception ex) { _logger.LogWarning(ex, "Error compacting file {file}", filePath); @@ -818,9 +925,9 @@ public sealed class FileCompactor : IDisposable } } } - catch (OperationCanceledException) - { - _logger.LogDebug("Compaction queue cancelled"); + catch (OperationCanceledException) + { + // Shutting down worker, this exception is expected } } @@ -836,17 +943,21 @@ public sealed class FileCompactor : IDisposable [DllImport("WofUtil.dll", SetLastError = true)] private static extern int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length); + private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + public void Dispose() { + _fragBatch?.Dispose() _compactionQueue.Writer.TryComplete(); _compactionCts.Cancel(); + try { - _compactionWorker.Wait(TimeSpan.FromSeconds(5)); + Task.WaitAll([.. _workers.Where(t => t != null)], TimeSpan.FromSeconds(5)); } catch { - //ignore on catch ^^ + // Ignore this catch on the dispose } finally { diff --git a/LightlessSync/Services/Compression/BatchFileFragService.cs b/LightlessSync/Services/Compression/BatchFileFragService.cs new file mode 100644 index 0000000..ae5bb71 --- /dev/null +++ b/LightlessSync/Services/Compression/BatchFileFragService.cs @@ -0,0 +1,245 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Text.RegularExpressions; +using System.Threading.Channels; + +namespace LightlessSync.Services.Compression +{ + /// + /// This batch service is made for the File Frag command, because of each file needing to use this command. + /// It's better to combine into one big command in batches then doing each command on each compressed call. + /// + public sealed partial class BatchFilefragService : IDisposable + { + private readonly Channel<(string path, TaskCompletionSource tcs)> _ch; + private readonly Task _worker; + private readonly bool _useShell; + private readonly ILogger _log; + private readonly int _batchSize; + private readonly TimeSpan _flushDelay; + private readonly CancellationTokenSource _cts = new(); + + public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25) + { + _useShell = useShell; + _log = log; + _batchSize = Math.Max(8, batchSize); + _flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs)); + _ch = Channel.CreateUnbounded<(string, TaskCompletionSource)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + _worker = Task.Run(ProcessAsync, _cts.Token); + } + + /// + /// Checks if the file is compressed using Btrfs using tasks + /// + /// Linux/Wine path given for the file. + /// Cancellation Token + /// If it was compressed or not + public Task IsCompressedAsync(string linuxPath, CancellationToken ct = default) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (!_ch.Writer.TryWrite((linuxPath, tcs))) + { + tcs.TrySetResult(false); + return tcs.Task; + } + + if (ct.CanBeCanceled) + { + var reg = ct.Register(() => tcs.TrySetCanceled(ct)); + _ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + + return tcs.Task; + } + + /// + /// Process the pending compression tasks asynchronously + /// + /// Task + private async Task ProcessAsync() + { + var reader = _ch.Reader; + var pending = new List<(string path, TaskCompletionSource tcs)>(_batchSize); + + try + { + while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false)) + { + if (!reader.TryRead(out var first)) continue; + pending.Add(first); + + var flushAt = DateTime.UtcNow + _flushDelay; + while (pending.Count < _batchSize && DateTime.UtcNow < flushAt) + { + if (reader.TryRead(out var item)) + { + pending.Add(item); + continue; + } + + if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break; + try + { + await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false); + } + catch + { + break; + } + } + + try + { + var map = await RunBatchAsync(pending.Select(p => p.path)).ConfigureAwait(false); + foreach (var (path, tcs) in pending) + { + tcs.TrySetResult(map.TryGetValue(path, out var c) && c); + } + } + catch (Exception ex) + { + _log.LogDebug(ex, "filefrag batch failed. falling back to false"); + foreach (var (_, tcs) in pending) + { + tcs.TrySetResult(false); + } + } + finally + { + pending.Clear(); + } + } + } + catch (OperationCanceledException) + { + //Shutting down worker, exception called + } + } + + /// + /// Running the batch of each file in the queue in one file frag command. + /// + /// Paths that are needed for the command building for the batch return + /// Path of the file and if it went correctly + /// Failing to start filefrag on the system if this exception is found + private async Task> RunBatchAsync(IEnumerable paths) + { + var list = paths.Distinct(StringComparer.Ordinal).ToList(); + var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal); + + ProcessStartInfo psi; + if (_useShell) + { + var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle)); + psi = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = "-c " + QuoteDouble(inner), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = "/" + }; + } + else + { + psi = new ProcessStartInfo + { + FileName = "filefrag", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-v"); + psi.ArgumentList.Add("--"); + foreach (var p in list) psi.ArgumentList.Add(p); + } + + using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start filefrag"); + var stdoutTask = proc.StandardOutput.ReadToEndAsync(_cts.Token); + var stderrTask = proc.StandardError.ReadToEndAsync(_cts.Token); + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + try + { + await proc.WaitForExitAsync(_cts.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Error in the batch frag service. proc = {proc}", proc); + } + + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + + if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) + _log.LogTrace("filefrag exited {code}: {err}", proc.ExitCode, stderr.Trim()); + + ParseFilefrag(stdout, result); + return result; + } + + /// + /// Parsing the string given from the File Frag command into mapping + /// + /// Output of the process from the File Frag + /// Mapping of the processed files + private static void ParseFilefrag(string output, Dictionary map) + { + var reHeaderColon = ColonRegex(); + var reHeaderSize = SizeRegex(); + + string? current = null; + using var sr = new StringReader(output); + for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine()) + { + var m1 = reHeaderColon.Match(line); + if (m1.Success) { current = m1.Groups[1].Value; continue; } + + var m2 = reHeaderSize.Match(line); + if (m2.Success) { current = m2.Groups[1].Value; continue; } + + if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) && + line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current)) + { + map[current] = true; + } + } + } + + private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + private static string QuoteDouble(string s) => "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("$", "\\$", StringComparison.Ordinal).Replace("`", "\\`", StringComparison.Ordinal) + "\""; + + /// + /// Regex of the File Size return on the Linux/Wine systems, giving back the amount + /// + /// Regex of the File Size + [GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)] + private static partial Regex SizeRegex(); + + /// + /// Regex on colons return on the Linux/Wine systems + /// + /// Regex of the colons in the given path + [GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)] + private static partial Regex ColonRegex(); + + public void Dispose() + { + _ch.Writer.TryComplete(); + _cts.Cancel(); + try + { + _worker.Wait(TimeSpan.FromSeconds(2), _cts.Token); + } + catch + { + // Ignore the catch in dispose + } + _cts.Dispose(); + } + } +} \ No newline at end of file -- 2.49.1 From e9082ab8d0c5f79323eed1037ee396e05ab9c410 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 7 Nov 2025 06:07:34 +0100 Subject: [PATCH 027/140] forget semicolomn.. --- LightlessSync/FileCache/FileCompactor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index e6505b9..be89c1f 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -947,7 +947,7 @@ public sealed class FileCompactor : IDisposable public void Dispose() { - _fragBatch?.Dispose() + _fragBatch?.Dispose(); _compactionQueue.Writer.TryComplete(); _compactionCts.Cancel(); -- 2.49.1 From d7182e9d5760bbc53f1d9546fc264589529355f7 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 10 Nov 2025 03:52:37 +0100 Subject: [PATCH 028/140] Hopefully fixes all issues with linux based path finding --- LightlessSync/FileCache/FileCompactor.cs | 491 +++++++++++------- .../Compression/BatchFileFragService.cs | 44 +- LightlessSync/Utils/FileSystemHelper.cs | 16 +- 3 files changed, 347 insertions(+), 204 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index be89c1f..e6d67ca 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -82,7 +82,7 @@ public sealed class FileCompactor : IDisposable _fragBatch = new BatchFilefragService( useShell: _dalamudUtilService.IsWine, log: _logger, - batchSize: 128, + batchSize: 256, flushMs: 25); _logger.LogInformation("FileCompactor started with {workers} workers", workerCount); @@ -192,23 +192,32 @@ public sealed class FileCompactor : IDisposable { try { - bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = isWine ? ToLinuxPathIfWine(fileInfo.FullName, isWine) : fileInfo.FullName; + bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var (_, linuxPath) = ResolvePathsForBtrfs(fileInfo.FullName); - (bool ok, string stdout, string stderr, int code) = - RunProcessDirect("stat", ["-c", "%b", realPath]); + var (ok1, out1, err1, code1) = + isWindowsProc + ? RunProcessShell($"stat -c %b -- {QuoteSingle(linuxPath)}", null, 10000) + : RunProcessDirect("stat", new[] { "-c", "%b", "--", linuxPath }, null, 10000); - if (!ok || !long.TryParse(stdout.Trim(), out var blocks)) - throw new InvalidOperationException($"stat failed (exit {code}): {stderr}"); + if (ok1 && long.TryParse(out1.Trim(), out long blocks)) + return (false, blocks * 512L); // st_blocks are 512B units - return (flowControl: false, value: blocks * 512L); + // Fallback: du -B1 (true on-disk bytes) + var (ok2, out2, err2, code2) = RunProcessShell($"du -B1 -- {QuoteSingle(linuxPath)} | cut -f1", null, 10000); // use shell for the pipe + + if (ok2 && long.TryParse(out2.Trim(), out long bytes)) + return (false, bytes); + + _logger.LogDebug("Btrfs size probe failed for {linux} (stat {code1}, du {code2}). Falling back to Length.", + linuxPath, code1, code2); + return (false, fileInfo.Length); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); + _logger.LogDebug(ex, "Failed Btrfs size probe for {file}, using Length", fileInfo.FullName); + return (false, fileInfo.Length); } - - return (flowControl: true, value: default); } /// @@ -357,59 +366,56 @@ public sealed class FileCompactor : IDisposable /// Decompressing state private bool DecompressBtrfsFile(string path) { - try + return RunWithBtrfsGate(() => { - _btrfsGate.Wait(_compactionCts.Token); - bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - - var mountOptions = GetMountOptionsForPath(realPath); - if (mountOptions.Contains("compress", StringComparison.OrdinalIgnoreCase)) + try { - _logger.LogWarning( - "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). " + - "Remount with 'compress=no' before running decompression.", - realPath, mountOptions); - return false; - } + var (winPath, linuxPath) = ResolvePathsForBtrfs(path); + bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - if (!IsBtrfsCompressedFile(realPath)) - { - _logger.LogTrace("File {file} is not compressed, skipping decompression.", realPath); + var opts = GetMountOptionsForPath(linuxPath); + if (opts.Contains("compress", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no'.", + linuxPath, opts); + return false; + } + + if (!IsBtrfsCompressedFile(linuxPath)) + { + _logger.LogTrace("Btrfs: not compressed, skip {file}", linuxPath); + return true; + } + + if (!ProbeFileReadableForBtrfs(winPath, linuxPath)) + return false; + + // Rewrite file uncompressed + (bool ok, string stdout, string stderr, int code) = + isWindowsProc + ? RunProcessShell($"btrfs filesystem defragment -- {QuoteSingle(linuxPath)}") + : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", linuxPath]); + + if (!ok) + { + _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {stderr}", + linuxPath, code, stderr); + return false; + } + + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogTrace("btrfs (decompress) output {file}: {out}", linuxPath, stdout.Trim()); + + _logger.LogInformation("Decompressed (rewritten) Btrfs file: {file}", linuxPath); return true; } - - if (!ProbeFileReadable(realPath)) - return false; - - (bool ok, string stdout, string stderr, int code) = - isWine - ? RunProcessShell($"btrfs filesystem defragment -- {QuoteSingle(realPath)}") - : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", realPath]); - - if (!ok) + catch (Exception ex) { - _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {stderr}", - realPath, code, stderr); + _logger.LogWarning(ex, "Error rewriting {file} for Btrfs decompression", path); return false; } - - if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); - - _logger.LogInformation("Decompressed (rewritten) Btrfs file: {file}", realPath); - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error rewriting {file} for Btrfs decompression", path); - return false; - } - finally - { - if (_btrfsGate.CurrentCount < 4) - _btrfsGate.Release(); - } + }); } /// @@ -459,19 +465,70 @@ public sealed class FileCompactor : IDisposable /// Path that has to be converted /// Extra check if using the wine enviroment /// Converted path to be used in Linux - private string ToLinuxPathIfWine(string path, bool isWine) + private static string ToLinuxPathIfWine(string path, bool isWine) { - if (!IsProbablyWine() && !isWine) + if (!isWine || !IsProbablyWine()) return path; - string linuxPath = path; - if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - linuxPath = "/" + path[3..].Replace('\\', '/'); - else if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) - linuxPath = Path.Combine(Environment.GetEnvironmentVariable("HOME") ?? "/home", path[3..].Replace('\\', '/')).Replace('\\', '/'); + if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) + return "/" + path[3..].Replace('\\', '/'); - _logger.LogTrace("Detected Wine environment. Converted path for compression: {realPath}", linuxPath); - return linuxPath; + if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) + { + var p = path.Replace('/', '\\'); + + const string usersPrefix = "C:\\Users\\"; + if (p.StartsWith(usersPrefix, StringComparison.OrdinalIgnoreCase)) + { + int afterUsers = usersPrefix.Length; + int slash = p.IndexOf('\\', afterUsers); + if (slash > 0 && slash + 1 < p.Length) + { + var rel = p[(slash + 1)..].Replace('\\', '/'); + var home = Environment.GetEnvironmentVariable("HOME"); + if (string.IsNullOrEmpty(home)) + { + var linuxUser = Environment.GetEnvironmentVariable("USER") ?? Environment.UserName; + home = "/home/" + linuxUser; + } + // Join as Unix path + return (home.TrimEnd('/') + "/" + rel).Replace("//", "/", StringComparison.Ordinal); + } + } + + try + { + var inner = "winepath -u " + "'" + path.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + var psi = new ProcessStartInfo + { + FileName = "/bin/bash", + Arguments = "-c " + "\"" + inner.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = "/" + }; + using var proc = Process.Start(psi); + var outp = proc?.StandardOutput.ReadToEnd().Trim(); + try + { + proc?.WaitForExit(); + } + catch + { + /* Wine can throw here; ignore */ + } + if (!string.IsNullOrEmpty(outp) && outp.StartsWith("/", StringComparison.Ordinal)) + return outp; + } + catch + { + /* ignore and fall through */ + } + } + + return path.Replace('\\', '/'); } /// @@ -589,25 +646,31 @@ public sealed class FileCompactor : IDisposable /// State of the file private bool IsBtrfsCompressedFile(string path) { - try + return RunWithBtrfsGate(() => { - _btrfsGate.Wait(_compactionCts.Token); + try + { + bool windowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + string linuxPath = windowsProc ? ResolveLinuxPathForWine(path) : path; - bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; + var task = _fragBatch.IsCompressedAsync(linuxPath, _compactionCts.Token); - return _fragBatch.IsCompressedAsync(realPath, _compactionCts.Token).GetAwaiter().GetResult(); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to detect Btrfs compression for {file}", path); - return false; - } - finally - { - if (_btrfsGate.CurrentCount < 4) - _btrfsGate.Release(); - } + if (task.Wait(TimeSpan.FromSeconds(5), _compactionCts.Token) && task.IsCompletedSuccessfully) + return task.Result; + + _logger.LogTrace("filefrag batch timed out for {file}", linuxPath); + return false; + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "filefrag batch check failed for {file}", path); + return false; + } + }); } /// @@ -617,85 +680,48 @@ public sealed class FileCompactor : IDisposable /// Compessing state private bool BtrfsCompressFile(string path) { - try - { - bool isWine = _dalamudUtilService?.IsWine ?? false; - string realPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; - - var fi = new FileInfo(realPath); - - if (fi == null) - { - _logger.LogWarning("Failed to open {file} for compression; skipping", realPath); - return false; - } - - if (IsBtrfsCompressedFile(realPath)) - { - _logger.LogTrace("File {file} already compressed (Btrfs), skipping file", realPath); - return true; - } - - if (!ProbeFileReadable(realPath)) - return false; - - (bool ok, string stdout, string stderr, int code) = - isWine - ? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(realPath)}") - : RunProcessDirect("btrfs", ["filesystem", "defragment", "-clzo", "--", realPath]); - - if (!ok) - { - _logger.LogWarning("btrfs defragment failed for {file} (exit {code}): {stderr}", realPath, code, stderr); - return false; - } - - if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs output for {file}: {stdout}", realPath, stdout.Trim()); - - if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs defragment output for {file}: {stdout}", realPath, stdout.Trim()); - - _logger.LogInformation("Compressed btrfs file successfully: {file}", realPath); - - return true; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error running btrfs defragment for {file}", path); - return false; - } - } - - - /// - /// Probe file if its readable for certain amount of tries. - /// - /// Path where the file is located - /// Filestream used for the function - /// State of the filestream opening - private bool ProbeFileReadable(string path) - { - for (int attempt = 0; attempt < _maxRetries; attempt++) - { + return RunWithBtrfsGate(() => { try { - using var _ = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - return true; - } - catch (IOException ex) - { - if (attempt == _maxRetries - 1) + var (winPath, linuxPath) = ResolvePathsForBtrfs(path); + bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + if (IsBtrfsCompressedFile(linuxPath)) { - _logger.LogWarning(ex, "File still in use after {attempts} attempts, skipping {file}", _maxRetries, path); + _logger.LogTrace("Already Btrfs compressed: {file} (linux={linux})", winPath, linuxPath); + return true; + } + + if (!ProbeFileReadableForBtrfs(winPath, linuxPath)) + { + _logger.LogTrace("Probe failed; cannot open file for compress: {file} (linux={linux})", winPath, linuxPath); return false; } - int delay = 150 * (attempt + 1); - _logger.LogTrace(ex, "File busy, retrying in {delay}ms for {file}", delay, path); - Thread.Sleep(delay); + + (bool ok, string stdout, string stderr, int code) = + isWindowsProc + ? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(linuxPath)}") + : RunProcessDirect("btrfs", ["filesystem", "defragment", "-clzo", "--", linuxPath]); + + if (!ok) + { + _logger.LogWarning("btrfs defragment failed for {file} (linux={linux}) exit {code}: {stderr}", + winPath, linuxPath, code, stderr); + return false; + } + + if (!string.IsNullOrWhiteSpace(stdout)) + _logger.LogTrace("btrfs output for {file}: {out}", winPath, stdout.Trim()); + + _logger.LogInformation("Compressed btrfs file successfully: {file} (linux={linux})", winPath, linuxPath); + return true; } - } - return false; + catch (Exception ex) + { + _logger.LogWarning(ex, "Error running btrfs defragment for {file}", path); + return false; + } + }); } /// @@ -742,7 +768,7 @@ public sealed class FileCompactor : IDisposable } /// - /// Runs an nonshell process meant for Linux/Wine enviroments + /// Runs an nonshell process meant for Linux enviroments /// /// File that has to be excuted /// Arguments meant for the file/command @@ -759,32 +785,47 @@ public sealed class FileCompactor : IDisposable CreateNoWindow = true }; if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; - foreach (var a in args) psi.ArgumentList.Add(a); + EnsureUnixPathEnv(psi); using var proc = Process.Start(psi); if (proc is null) return (false, "", "failed to start process", -1); var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); + var both = Task.WhenAll(outTask, errTask); + + if (_dalamudUtilService.IsWine) + { + var finished = Task.WhenAny(both, Task.Delay(timeoutMs, _compactionCts.Token)).GetAwaiter().GetResult(); + if (finished != both) + { + try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } + try { Task.WaitAll(new[] { outTask, errTask }, 1000, _compactionCts.Token); } catch { /* ignore this */ } + var so = outTask.IsCompleted ? outTask.Result : ""; + var se = errTask.IsCompleted ? errTask.Result : "timeout"; + return (false, so, se, -1); + } + + var stdout = outTask.Result; + var stderr = errTask.Result; + var ok = string.IsNullOrWhiteSpace(stderr); + return (ok, stdout, stderr, ok ? 0 : -1); + } if (!proc.WaitForExit(timeoutMs)) { - try - { - proc.Kill(entireProcessTree: true); - } - catch - { - // Ignore this catch on the dispose - } - - Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); - return (false, outTask.Result, "timeout", -1); + try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } + try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } + return (false, outTask.IsCompleted ? outTask.Result : "", "timeout", -1); } Task.WaitAll(outTask, errTask); - return (proc.ExitCode == 0, outTask.Result, errTask.Result, proc.ExitCode); + var so2 = outTask.Result; + var se2 = errTask.Result; + int code; + try { code = proc.ExitCode; } catch { code = -1; } + return (code == 0, so2, se2, code); } /// @@ -793,8 +834,9 @@ public sealed class FileCompactor : IDisposable /// Command that has to be excuted /// Timeout timer for the process /// State of the process, output of the process and error with exit code - private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, int timeoutMs = 60000) + private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, string? workingDir = null, int timeoutMs = 60000) { + // Use a LOGIN shell so PATH includes /usr/sbin etc. var psi = new ProcessStartInfo("/bin/bash") { RedirectStandardOutput = true, @@ -802,32 +844,50 @@ public sealed class FileCompactor : IDisposable UseShellExecute = false, CreateNoWindow = true }; - psi.ArgumentList.Add("-c"); - psi.ArgumentList.Add(command); + if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; + + psi.ArgumentList.Add("-lc"); + psi.ArgumentList.Add(QuoteDouble(command)); + EnsureUnixPathEnv(psi); using var proc = Process.Start(psi); if (proc is null) return (false, "", "failed to start /bin/bash", -1); var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); + var both = Task.WhenAll(outTask, errTask); + + if (_dalamudUtilService.IsWine) + { + var finished = Task.WhenAny(both, Task.Delay(timeoutMs, _compactionCts.Token)).GetAwaiter().GetResult(); + if (finished != both) + { + try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } + try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } + var so = outTask.IsCompleted ? outTask.Result : ""; + var se = errTask.IsCompleted ? errTask.Result : "timeout"; + return (false, so, se, -1); + } + + var stdout = outTask.Result; + var stderr = errTask.Result; + var ok = string.IsNullOrWhiteSpace(stderr); + return (ok, stdout, stderr, ok ? 0 : -1); + } if (!proc.WaitForExit(timeoutMs)) { - try - { - proc.Kill(entireProcessTree: true); - } - catch - { - // Ignore this catch on the dispose - } - - Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); - return (false, outTask.Result, "timeout", -1); + try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } + try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } + return (false, outTask.IsCompleted ? outTask.Result : "", "timeout", -1); } Task.WaitAll(outTask, errTask); - return (proc.ExitCode == 0, outTask.Result, errTask.Result, proc.ExitCode); + var so2 = outTask.Result; + var se2 = errTask.Result; + int code; + try { code = proc.ExitCode; } catch { code = -1; } + return (code == 0, so2, se2, code); } /// @@ -931,6 +991,69 @@ public sealed class FileCompactor : IDisposable } } + private string ResolveLinuxPathForWine(string windowsPath) + { + var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", null, 5000); + if (ok && !string.IsNullOrWhiteSpace(outp)) return outp.Trim(); + return ToLinuxPathIfWine(windowsPath, isWine: true); + } + + private static void EnsureUnixPathEnv(ProcessStartInfo psi) + { + if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin"; + else if (!p.Contains("/usr/sbin", StringComparison.Ordinal)) + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; + } + + private (string windowsPath, string linuxPath) ResolvePathsForBtrfs(string path) + { + bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + if (!isWindowsProc) + return (path, path); + + // Prefer winepath -u; fall back to your existing mapper + var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(path)}", null, 5000); + var linux = (ok && !string.IsNullOrWhiteSpace(outp)) ? outp.Trim() + : ToLinuxPathIfWine(path, isWine: true); + + return (path, linux); + } + + private bool ProbeFileReadableForBtrfs(string windowsPath, string linuxPath) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using var _ = new FileStream(windowsPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + else + { + using var _ = new FileStream(linuxPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + return true; + } + catch { return false; } + } + + private T RunWithBtrfsGate(Func body) + { + bool acquired = false; + try + { + _btrfsGate.Wait(_compactionCts.Token); + acquired = true; + return body(); + } + finally + { + if (acquired) _btrfsGate.Release(); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); @@ -945,6 +1068,8 @@ public sealed class FileCompactor : IDisposable private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + private static string QuoteDouble(string s) => "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("$", "\\$", StringComparison.Ordinal).Replace("`", "\\`", StringComparison.Ordinal) + "\""; + public void Dispose() { _fragBatch?.Dispose(); diff --git a/LightlessSync/Services/Compression/BatchFileFragService.cs b/LightlessSync/Services/Compression/BatchFileFragService.cs index ae5bb71..16c05e1 100644 --- a/LightlessSync/Services/Compression/BatchFileFragService.cs +++ b/LightlessSync/Services/Compression/BatchFileFragService.cs @@ -136,13 +136,18 @@ namespace LightlessSync.Services.Compression psi = new ProcessStartInfo { FileName = "/bin/bash", - Arguments = "-c " + QuoteDouble(inner), + Arguments = "-lc " + QuoteDouble(inner), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = "/" }; + + if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin"; + else + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; } else { @@ -154,29 +159,38 @@ namespace LightlessSync.Services.Compression UseShellExecute = false, CreateNoWindow = true }; + + if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin"; + else + psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; + psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("--"); - foreach (var p in list) psi.ArgumentList.Add(p); + foreach (var path in list) + psi.ArgumentList.Add(path); } using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start filefrag"); - var stdoutTask = proc.StandardOutput.ReadToEndAsync(_cts.Token); - var stderrTask = proc.StandardError.ReadToEndAsync(_cts.Token); - await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); - try - { - await proc.WaitForExitAsync(_cts.Token).ConfigureAwait(false); - } - catch (Exception ex) + + var outTask = proc.StandardOutput.ReadToEndAsync(_cts.Token); + var errTask = proc.StandardError.ReadToEndAsync(_cts.Token); + + var timeout = TimeSpan.FromSeconds(15); + var combined = Task.WhenAll(outTask, errTask); + var finished = await Task.WhenAny(combined, Task.Delay(timeout, _cts.Token)).ConfigureAwait(false); + + if (finished != combined) { - _log.LogWarning(ex, "Error in the batch frag service. proc = {proc}", proc); + try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ } + try { await combined.ConfigureAwait(false); } catch { /* ignore */ } } - var stdout = await stdoutTask.ConfigureAwait(false); - var stderr = await stderrTask.ConfigureAwait(false); + var stdout = outTask.IsCompletedSuccessfully ? await outTask.ConfigureAwait(false) : ""; + var stderr = errTask.IsCompletedSuccessfully ? await errTask.ConfigureAwait(false) : ""; - if (proc.ExitCode != 0 && !string.IsNullOrWhiteSpace(stderr)) - _log.LogTrace("filefrag exited {code}: {err}", proc.ExitCode, stderr.Trim()); + if (!string.IsNullOrWhiteSpace(stderr)) + _log.LogTrace("filefrag stderr (batch): {err}", stderr.Trim()); ParseFilefrag(stdout, result); return result; diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index af4c98b..d63b3b9 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -252,14 +252,18 @@ namespace LightlessSync.Utils }; using var proc = Process.Start(psi); - string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? ""; - proc?.WaitForExit(); - if (int.TryParse(stdout, out int blockSize) && blockSize > 0) + string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? ""; + string _stderr = proc?.StandardError.ReadToEnd() ?? ""; + + try { proc?.WaitForExit(); } + catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); } + + if (!(!int.TryParse(stdout, out int block) || block <= 0)) { - _blockSizeCache[root] = blockSize; - logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, blockSize); - return blockSize; + _blockSizeCache[root] = block; + logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block); + return block; } logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout); -- 2.49.1 From 7de72471bb96f091aba2db5c81440d53a5f72976 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 10 Nov 2025 06:25:35 +0100 Subject: [PATCH 029/140] Refactored --- LightlessSync/FileCache/FileCompactor.cs | 171 ++++++++++++++--------- 1 file changed, 102 insertions(+), 69 deletions(-) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index e6d67ca..5e0b4a9 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -6,6 +6,7 @@ using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -82,7 +83,7 @@ public sealed class FileCompactor : IDisposable _fragBatch = new BatchFilefragService( useShell: _dalamudUtilService.IsWine, log: _logger, - batchSize: 256, + batchSize: 128, flushMs: 25); _logger.LogInformation("FileCompactor started with {workers} workers", workerCount); @@ -198,13 +199,12 @@ public sealed class FileCompactor : IDisposable var (ok1, out1, err1, code1) = isWindowsProc ? RunProcessShell($"stat -c %b -- {QuoteSingle(linuxPath)}", null, 10000) - : RunProcessDirect("stat", new[] { "-c", "%b", "--", linuxPath }, null, 10000); + : RunProcessDirect("stat", ["-c", "%b", "--", linuxPath], null, 10000); if (ok1 && long.TryParse(out1.Trim(), out long blocks)) - return (false, blocks * 512L); // st_blocks are 512B units + return (false, blocks * 512L); // st_blocks are always 512B units - // Fallback: du -B1 (true on-disk bytes) - var (ok2, out2, err2, code2) = RunProcessShell($"du -B1 -- {QuoteSingle(linuxPath)} | cut -f1", null, 10000); // use shell for the pipe + var (ok2, out2, err2, code2) = RunProcessShell($"du -B1 -- {QuoteSingle(linuxPath)} | cut -f1", workingDir: null, 10000); // use shell for the pipe if (ok2 && long.TryParse(out2.Trim(), out long bytes)) return (false, bytes); @@ -425,6 +425,7 @@ public sealed class FileCompactor : IDisposable /// Decompressing state private bool DecompressWOFFile(string path) { + //Check if its already been compressed if (TryIsWofExternal(path, out bool isExternal, out int algo)) { if (!isExternal) @@ -436,6 +437,7 @@ public sealed class FileCompactor : IDisposable _logger.LogTrace("WOF compression (algo={algo}) detected for {file}", compressString, path); } + //This will attempt to start WOF thread. return WithFileHandleForWOF(path, FileAccess.ReadWrite, h => { if (!DeviceIoControl(h, FSCTL_DELETE_EXTERNAL_BACKING, @@ -524,7 +526,7 @@ public sealed class FileCompactor : IDisposable } catch { - /* ignore and fall through */ + /* ignore and fall through the floor! */ } } @@ -550,7 +552,7 @@ public sealed class FileCompactor : IDisposable { int ret = WofSetFileDataLocation(h, WOF_PROVIDER_FILE, efInfoPtr, length); - // 0x80070158 is the benign "already compressed/unsupported" style return + // 0x80070158 is the being "already compressed/unsupported" style return if (ret != 0 && ret != unchecked((int)0x80070158)) { _logger.LogWarning("Failed to compact {file}: {ret}", path, ret.ToString("X")); @@ -791,38 +793,12 @@ public sealed class FileCompactor : IDisposable using var proc = Process.Start(psi); if (proc is null) return (false, "", "failed to start process", -1); - var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); - var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); - var both = Task.WhenAll(outTask, errTask); - - if (_dalamudUtilService.IsWine) + var (success, so2, se2) = CheckProcessResult(proc, timeoutMs, _compactionCts.Token); + if (!success) { - var finished = Task.WhenAny(both, Task.Delay(timeoutMs, _compactionCts.Token)).GetAwaiter().GetResult(); - if (finished != both) - { - try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } - try { Task.WaitAll(new[] { outTask, errTask }, 1000, _compactionCts.Token); } catch { /* ignore this */ } - var so = outTask.IsCompleted ? outTask.Result : ""; - var se = errTask.IsCompleted ? errTask.Result : "timeout"; - return (false, so, se, -1); - } - - var stdout = outTask.Result; - var stderr = errTask.Result; - var ok = string.IsNullOrWhiteSpace(stderr); - return (ok, stdout, stderr, ok ? 0 : -1); + return (false, so2, se2, -1); } - if (!proc.WaitForExit(timeoutMs)) - { - try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } - try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } - return (false, outTask.IsCompleted ? outTask.Result : "", "timeout", -1); - } - - Task.WaitAll(outTask, errTask); - var so2 = outTask.Result; - var se2 = errTask.Result; int code; try { code = proc.ExitCode; } catch { code = -1; } return (code == 0, so2, se2, code); @@ -836,7 +812,7 @@ public sealed class FileCompactor : IDisposable /// State of the process, output of the process and error with exit code private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, string? workingDir = null, int timeoutMs = 60000) { - // Use a LOGIN shell so PATH includes /usr/sbin etc. + var psi = new ProcessStartInfo("/bin/bash") { RedirectStandardOutput = true, @@ -845,7 +821,7 @@ public sealed class FileCompactor : IDisposable CreateNoWindow = true }; if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; - + // Use a Login shell so PATH includes /usr/sbin etc. AKA -lc psi.ArgumentList.Add("-lc"); psi.ArgumentList.Add(QuoteDouble(command)); EnsureUnixPathEnv(psi); @@ -853,43 +829,74 @@ public sealed class FileCompactor : IDisposable using var proc = Process.Start(psi); if (proc is null) return (false, "", "failed to start /bin/bash", -1); - var outTask = proc.StandardOutput.ReadToEndAsync(_compactionCts.Token); - var errTask = proc.StandardError.ReadToEndAsync(_compactionCts.Token); - var both = Task.WhenAll(outTask, errTask); - - if (_dalamudUtilService.IsWine) + var (success, so2, se2) = CheckProcessResult(proc, timeoutMs, _compactionCts.Token); + if (!success) { - var finished = Task.WhenAny(both, Task.Delay(timeoutMs, _compactionCts.Token)).GetAwaiter().GetResult(); - if (finished != both) - { - try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } - try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } - var so = outTask.IsCompleted ? outTask.Result : ""; - var se = errTask.IsCompleted ? errTask.Result : "timeout"; - return (false, so, se, -1); - } - - var stdout = outTask.Result; - var stderr = errTask.Result; - var ok = string.IsNullOrWhiteSpace(stderr); - return (ok, stdout, stderr, ok ? 0 : -1); + return (false, so2, se2, -1); } - if (!proc.WaitForExit(timeoutMs)) - { - try { proc.Kill(entireProcessTree: true); } catch { /* ignore this */ } - try { Task.WaitAll([outTask, errTask], 1000, _compactionCts.Token); } catch { /* ignore this */ } - return (false, outTask.IsCompleted ? outTask.Result : "", "timeout", -1); - } - - Task.WaitAll(outTask, errTask); - var so2 = outTask.Result; - var se2 = errTask.Result; int code; try { code = proc.ExitCode; } catch { code = -1; } return (code == 0, so2, se2, code); } + /// + /// Checking the process result for shell or direct processes + /// + /// Process + /// How long when timeout is gotten + /// Cancellation Token + /// Multiple variables + private (bool success, string testy, string testi) CheckProcessResult(Process proc, int timeoutMs, CancellationToken token) + { + var outTask = proc.StandardOutput.ReadToEndAsync(token); + var errTask = proc.StandardError.ReadToEndAsync(token); + var bothTasks = Task.WhenAll(outTask, errTask); + + //On wine, we dont wanna use waitforexit as it will be always broken and giving an error. + if (_dalamudUtilService.IsWine) + { + var finished = Task.WhenAny(bothTasks, Task.Delay(timeoutMs, token)).GetAwaiter().GetResult(); + if (finished != bothTasks) + { + try + { + proc.Kill(entireProcessTree: true); + Task.WaitAll([outTask, errTask], 1000, token); + } + catch + { + // ignore this + } + var so = outTask.IsCompleted ? outTask.Result : ""; + var se = errTask.IsCompleted ? errTask.Result : "timeout"; + return (false, so, se); + } + + var stderr = errTask.Result; + var ok = string.IsNullOrWhiteSpace(stderr); + return (ok, outTask.Result, stderr); + } + + // On linux, we can use it as we please + if (!proc.WaitForExit(timeoutMs)) + { + try + { + proc.Kill(entireProcessTree: true); + Task.WaitAll([outTask, errTask], 1000, token); + } + catch + { + // ignore this + } + return (false, outTask.IsCompleted ? outTask.Result : "", "timeout"); + } + + Task.WaitAll(outTask, errTask); + return (true, outTask.Result, errTask.Result); + } + /// /// Enqueues the compaction/decompation of an filepath. /// @@ -991,6 +998,11 @@ public sealed class FileCompactor : IDisposable } } + /// + /// Resolves linux path from wine pathing + /// + /// Windows path given from Wine + /// Linux path to be used in Linux private string ResolveLinuxPathForWine(string windowsPath) { var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", null, 5000); @@ -998,6 +1010,10 @@ public sealed class FileCompactor : IDisposable return ToLinuxPathIfWine(windowsPath, isWine: true); } + /// + /// Ensures the Unix pathing to be included into the process + /// + /// Process private static void EnsureUnixPathEnv(ProcessStartInfo psi) { if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) @@ -1006,6 +1022,11 @@ public sealed class FileCompactor : IDisposable psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; } + /// + /// Resolves paths for Btrfs to be used on wine or linux and windows in case + /// + /// Path given t + /// private (string windowsPath, string linuxPath) ResolvePathsForBtrfs(string path) { bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -1021,13 +1042,19 @@ public sealed class FileCompactor : IDisposable return (path, linux); } - private bool ProbeFileReadableForBtrfs(string windowsPath, string linuxPath) + /// + /// Probes file if its readable to be used + /// + /// Windows path + /// Linux path + /// Succesfully probed or not + private bool ProbeFileReadableForBtrfs(string winePath, string linuxPath) { try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - using var _ = new FileStream(windowsPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var _ = new FileStream(winePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } else { @@ -1038,6 +1065,12 @@ public sealed class FileCompactor : IDisposable catch { return false; } } + /// + /// Running functions into the Btrfs Gate/Threading. + /// + /// Type of the function that wants to be run inside Btrfs Gate + /// Body of the function + /// Task private T RunWithBtrfsGate(Func body) { bool acquired = false; -- 2.49.1 From 8692e877cf377a8488cfb86ae011f4e9a5323c6d Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 10:59:42 +0100 Subject: [PATCH 030/140] download notification stuck fix, more x and y offset positions --- LightlessSync/Services/NotificationService.cs | 11 --------- LightlessSync/UI/DownloadUi.cs | 10 ++++---- LightlessSync/UI/LightlessNotificationUI.cs | 6 +++++ LightlessSync/UI/SettingsUi.cs | 24 +++++++++---------- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 72f4a16..8709710 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -342,12 +342,6 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ _ => download.Status }; - private bool AreAllDownloadsCompleted(List<(string PlayerName, float Progress, string Status)> userDownloads) => - userDownloads.Any() && userDownloads.All(x => x.Progress >= 1.0f); - - public void DismissPairDownloadNotification() => - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch { NotificationType.Info => TimeSpan.FromSeconds(_configService.Current.InfoNotificationDurationSeconds), @@ -607,11 +601,6 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ }; Mediator.Publish(new LightlessNotificationMessage(notification)); - - if (userDownloads.Count == 0 || AreAllDownloadsCompleted(userDownloads)) - { - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - } } private void HandlePerformanceNotification(PerformanceNotificationMessage msg) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index a592e43..2761b59 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -58,14 +58,14 @@ public class DownloadUi : WindowMediatorSubscriberBase IsOpen = true; - Mediator.Subscribe(this, (msg) => _currentDownloads[msg.DownloadId] = msg.DownloadStatus); + Mediator.Subscribe(this, (msg) => + { + _currentDownloads[msg.DownloadId] = msg.DownloadStatus; + _notificationDismissed = false; + }); Mediator.Subscribe(this, (msg) => { _currentDownloads.TryRemove(msg.DownloadId, out _); - if (!_currentDownloads.Any()) - { - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - } }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => IsOpen = true); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 3d2d748..2d412f2 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -86,6 +86,12 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase existing.Progress = updated.Progress; existing.ShowProgress = updated.ShowProgress; existing.Title = updated.Title; + + if (updated.Type == NotificationType.Download && updated.Progress < 1.0f) + { + existing.CreatedAt = DateTime.UtcNow; + } + _logger.LogDebug("Updated existing notification: {Title}", updated.Title); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index febc142..4054853 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3851,9 +3851,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Choose which corner of the screen notifications appear in."); int offsetY = _configService.Current.NotificationOffsetY; - if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 1000)) + if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 5000)) { - _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 1000); + _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 5000); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3866,9 +3866,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Distance from the top edge of the screen."); int offsetX = _configService.Current.NotificationOffsetX; - if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 500)) + if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 2500)) { - _configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 500); + _configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 2500); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3985,9 +3985,9 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Right click to reset to default (20)."); int pairRequestDuration = _configService.Current.PairRequestDurationSeconds; - if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 600)) + if (ImGui.SliderInt("Pair Request Duration (seconds)", ref pairRequestDuration, 30, 1800)) { - _configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 600); + _configService.Current.PairRequestDurationSeconds = Math.Clamp(pairRequestDuration, 30, 1800); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3999,23 +3999,23 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Right click to reset to default (180)."); int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds; - if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 600)) + if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 120)) { - _configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 600); + _configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 120); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) { - _configService.Current.DownloadNotificationDurationSeconds = 300; + _configService.Current.DownloadNotificationDurationSeconds = 30; _configService.Save(); } if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Right click to reset to default (300)."); + ImGui.SetTooltip("Right click to reset to default (30)."); int performanceDuration = _configService.Current.PerformanceNotificationDurationSeconds; - if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 60)) + if (ImGui.SliderInt("Performance Duration (seconds)", ref performanceDuration, 5, 120)) { - _configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 60); + _configService.Current.PerformanceNotificationDurationSeconds = Math.Clamp(performanceDuration, 5, 120); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) -- 2.49.1 From c02a8ed2eee044c845083779532f4df5b9ddd4a3 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 20:57:43 +0100 Subject: [PATCH 031/140] notification clickthrougable, update notes centered in the middle of the screen unmovable --- LightlessSync/UI/LightlessNotificationUI.cs | 3 +++ LightlessSync/UI/UpdateNotesUi.cs | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 2d412f2..65a6c69 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -45,6 +45,9 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoCollapse | + ImGuiWindowFlags.NoInputs | + ImGuiWindowFlags.NoTitleBar | + ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.AlwaysAutoResize; PositionCondition = ImGuiCond.Always; diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index bc60ab5..02e0b4d 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -76,13 +76,15 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase ShowCloseButton = true; Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse | - ImGuiWindowFlags.NoTitleBar; + ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove; SizeConstraints = new WindowSizeConstraints() { MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700), }; + PositionCondition = ImGuiCond.Always; + LoadEmbeddedResources(); logger.LogInformation("UpdateNotesUi constructor completed successfully"); } @@ -93,11 +95,20 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase _hasInitializedCollapsingHeaders = false; } + private void CenterWindow() + { + var viewport = ImGui.GetMainViewport(); + var center = viewport.GetCenter(); + var windowSize = new Vector2(800f * ImGuiHelpers.GlobalScale, 700f * ImGuiHelpers.GlobalScale); + Position = center - windowSize / 2f; + } + protected override void DrawInternal() { if (_uiShared.IsInGpose) return; + CenterWindow(); DrawHeader(); ImGuiHelpers.ScaledDummy(6); DrawTabs(); -- 2.49.1 From f89ce900c776a4e49c71492c1ca6f3b08bb2a8e6 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 21:05:39 +0100 Subject: [PATCH 032/140] more offset changes accepting minus for notifications till -2500 --- LightlessSync/UI/SettingsUi.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 4054853..f1da3c0 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3851,9 +3851,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Choose which corner of the screen notifications appear in."); int offsetY = _configService.Current.NotificationOffsetY; - if (ImGui.SliderInt("Vertical Offset", ref offsetY, 0, 5000)) + if (ImGui.SliderInt("Vertical Offset", ref offsetY, -2500, 2500)) { - _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, 0, 5000); + _configService.Current.NotificationOffsetY = Math.Clamp(offsetY, -2500, 2500); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -3866,9 +3866,9 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Distance from the top edge of the screen."); int offsetX = _configService.Current.NotificationOffsetX; - if (ImGui.SliderInt("Horizontal Offset", ref offsetX, 0, 2500)) + if (ImGui.SliderInt("Horizontal Offset", ref offsetX, -2500, 2500)) { - _configService.Current.NotificationOffsetX = Math.Clamp(offsetX, 0, 2500); + _configService.Current.NotificationOffsetX = Math.Clamp(offsetX, -2500, 2500); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) -- 2.49.1 From 25b03aea15ef7235509343e7af753ee617acae24 Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 21:22:28 +0100 Subject: [PATCH 033/140] download dismiss message if downloads are complete --- LightlessSync/UI/DownloadUi.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 2761b59..275b338 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -66,6 +66,14 @@ public class DownloadUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, (msg) => { _currentDownloads.TryRemove(msg.DownloadId, out _); + + // Dismiss notification if all downloads are complete + if (!_currentDownloads.Any() && !_notificationDismissed) + { + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); + _notificationDismissed = true; + _lastDownloadStateHash = 0; + } }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => IsOpen = true); -- 2.49.1 From 41a303dc9136d97aa1c23291536cb01a2daa633e Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 21:29:55 +0100 Subject: [PATCH 034/140] auto dismiss notifs if no updates --- LightlessSync/UI/DownloadUi.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 275b338..4f64c38 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -24,6 +24,8 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly ConcurrentDictionary _uploadingPlayers = new(); private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; + private DateTime _lastNotificationUpdate = DateTime.MinValue; + private const int NotificationAutoCloseSeconds = 15; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, @@ -147,6 +149,20 @@ public class DownloadUi : WindowMediatorSubscriberBase _notificationDismissed = true; _lastDownloadStateHash = 0; } + + // Auto-dismiss notification if no updates for 15 seconds + if (!_notificationDismissed && _lastNotificationUpdate != DateTime.MinValue) + { + var timeSinceLastUpdate = (DateTime.UtcNow - _lastNotificationUpdate).TotalSeconds; + if (timeSinceLastUpdate > NotificationAutoCloseSeconds) + { + _logger.LogDebug("Auto-dismissing download notification after {Seconds}s of inactivity", timeSinceLastUpdate); + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); + _notificationDismissed = true; + _lastDownloadStateHash = 0; + _lastNotificationUpdate = DateTime.MinValue; + } + } } else { @@ -363,11 +379,11 @@ public class DownloadUi : WindowMediatorSubscriberBase if (currentHash != _lastDownloadStateHash) { _lastDownloadStateHash = currentHash; + _lastNotificationUpdate = DateTime.UtcNow; if (downloadStatus.Count > 0 || queueWaiting > 0) { Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting)); } } } - } \ No newline at end of file -- 2.49.1 From 95e7f2daa7607676e38bc8d55524358553e4db0c Mon Sep 17 00:00:00 2001 From: choco Date: Mon, 10 Nov 2025 21:59:20 +0100 Subject: [PATCH 035/140] download notification progress and download bar, chat only option for notifications (idk why you would bother even enabling the lightless nofis then) --- .../Configurations/LightlessConfig.cs | 2 +- LightlessSync/UI/DownloadUi.cs | 17 --------- LightlessSync/UI/LightlessNotificationUI.cs | 35 +++++++++++++++---- LightlessSync/UI/SettingsUi.cs | 6 ++-- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index f849c87..929cbbc 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -113,7 +113,7 @@ public class LightlessConfig : ILightlessConfiguration public int WarningNotificationDurationSeconds { get; set; } = 15; public int ErrorNotificationDurationSeconds { get; set; } = 20; public int PairRequestDurationSeconds { get; set; } = 180; - public int DownloadNotificationDurationSeconds { get; set; } = 300; + public int DownloadNotificationDurationSeconds { get; set; } = 30; public int PerformanceNotificationDurationSeconds { get; set; } = 20; public uint CustomInfoSoundId { get; set; } = 2; // Se2 public uint CustomWarningSoundId { get; set; } = 16; // Se15 diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 4f64c38..aa3132a 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -24,8 +24,6 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly ConcurrentDictionary _uploadingPlayers = new(); private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; - private DateTime _lastNotificationUpdate = DateTime.MinValue; - private const int NotificationAutoCloseSeconds = 15; public DownloadUi(ILogger logger, DalamudUtilService dalamudUtilService, LightlessConfigService configService, PairProcessingLimiter pairProcessingLimiter, FileUploadManager fileTransferManager, LightlessMediator mediator, UiSharedService uiShared, @@ -149,20 +147,6 @@ public class DownloadUi : WindowMediatorSubscriberBase _notificationDismissed = true; _lastDownloadStateHash = 0; } - - // Auto-dismiss notification if no updates for 15 seconds - if (!_notificationDismissed && _lastNotificationUpdate != DateTime.MinValue) - { - var timeSinceLastUpdate = (DateTime.UtcNow - _lastNotificationUpdate).TotalSeconds; - if (timeSinceLastUpdate > NotificationAutoCloseSeconds) - { - _logger.LogDebug("Auto-dismissing download notification after {Seconds}s of inactivity", timeSinceLastUpdate); - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - _notificationDismissed = true; - _lastDownloadStateHash = 0; - _lastNotificationUpdate = DateTime.MinValue; - } - } } else { @@ -379,7 +363,6 @@ public class DownloadUi : WindowMediatorSubscriberBase if (currentHash != _lastDownloadStateHash) { _lastDownloadStateHash = currentHash; - _lastNotificationUpdate = DateTime.UtcNow; if (downloadStatus.Count > 0 || queueWaiting > 0) { Mediator.Publish(new PairDownloadStatusMessage(downloadStatus, queueWaiting)); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 65a6c69..8cb6922 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -90,7 +90,8 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase existing.ShowProgress = updated.ShowProgress; existing.Title = updated.Title; - if (updated.Type == NotificationType.Download && updated.Progress < 1.0f) + // Reset the duration timer on every update for download notifications + if (updated.Type == NotificationType.Download) { existing.CreatedAt = DateTime.UtcNow; } @@ -344,6 +345,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase DrawBackground(drawList, windowPos, windowSize, bgColor); DrawAccentBar(drawList, windowPos, windowSize, accentColor); DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList); + + // Draw download progress bar above duration bar for download notifications + if (notification.Type == NotificationType.Download && notification.ShowProgress) + { + DrawDownloadProgressBar(notification, alpha, windowPos, windowSize, drawList); + } + DrawNotificationText(notification, alpha); } @@ -425,7 +433,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { - var progress = CalculateProgress(notification); + var progress = CalculateDurationProgress(notification); var progressBarColor = UIColors.Get("LightlessBlue"); var progressHeight = 2f; var progressY = windowPos.Y + windowSize.Y - progressHeight; @@ -439,13 +447,26 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private float CalculateProgress(LightlessNotification notification) + private void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { - if (notification.Type == NotificationType.Download && notification.ShowProgress) - { - return Math.Clamp(notification.Progress, 0f, 1f); - } + var progress = Math.Clamp(notification.Progress, 0f, 1f); + var progressBarColor = UIColors.Get("LightlessGreen"); + var progressHeight = 3f; + // Position above the duration bar (2px duration bar + 1px spacing) + var progressY = windowPos.Y + windowSize.Y - progressHeight - 3f; + var progressWidth = windowSize.X * progress; + DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha); + + if (progress > 0) + { + DrawProgressForeground(drawList, windowPos, progressY, progressHeight, progressWidth, progressBarColor, alpha); + } + } + + private float CalculateDurationProgress(LightlessNotification notification) + { + // Calculate duration timer progress var elapsed = DateTime.UtcNow - notification.CreatedAt; return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index c841fd7..1b8aa12 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -3993,9 +3993,9 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Right click to reset to default (180)."); int downloadDuration = _configService.Current.DownloadNotificationDurationSeconds; - if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 60, 120)) + if (ImGui.SliderInt("Download Duration (seconds)", ref downloadDuration, 15, 120)) { - _configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 60, 120); + _configService.Current.DownloadNotificationDurationSeconds = Math.Clamp(downloadDuration, 15, 120); _configService.Save(); } if (ImGui.IsItemClicked(ImGuiMouseButton.Right)) @@ -4144,7 +4144,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { return new[] { - NotificationLocation.LightlessUi, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere + NotificationLocation.LightlessUi, NotificationLocation.Chat, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere }; } -- 2.49.1 From 1862689b1b37e355078b5420167b6338194f112e Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 11 Nov 2025 17:09:50 +0100 Subject: [PATCH 036/140] Changed some commands in file getting, redone compression check commands and turned off btrfs compactor for 1.12.4 --- LightlessSync/FileCache/FileCompactor.cs | 144 +++++++++--------- .../BatchFileFragService.cs | 91 ++++------- LightlessSync/UI/SettingsUi.cs | 10 +- 3 files changed, 106 insertions(+), 139 deletions(-) rename LightlessSync/Services/{Compression => Compactor}/BatchFileFragService.cs (71%) diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 5e0b4a9..3edf96a 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -1,12 +1,11 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services; -using LightlessSync.Services.Compression; +using LightlessSync.Services.Compactor; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; -using System.Threading; using System.Threading.Channels; using static LightlessSync.Utils.FileSystemHelper; @@ -28,6 +27,8 @@ public sealed class FileCompactor : IDisposable private readonly List _workers = []; private readonly SemaphoreSlim _globalGate; + + //Limit btrfs gate on half of threads given to compactor. private static readonly SemaphoreSlim _btrfsGate = new(4, 4); private readonly BatchFilefragService _fragBatch; @@ -83,8 +84,11 @@ public sealed class FileCompactor : IDisposable _fragBatch = new BatchFilefragService( useShell: _dalamudUtilService.IsWine, log: _logger, - batchSize: 128, - flushMs: 25); + batchSize: 64, + flushMs: 25, + runDirect: RunProcessDirect, + runShell: RunProcessShell + ); _logger.LogInformation("FileCompactor started with {workers} workers", workerCount); } @@ -196,21 +200,15 @@ public sealed class FileCompactor : IDisposable bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var (_, linuxPath) = ResolvePathsForBtrfs(fileInfo.FullName); - var (ok1, out1, err1, code1) = + var (ok, output, err, code) = isWindowsProc - ? RunProcessShell($"stat -c %b -- {QuoteSingle(linuxPath)}", null, 10000) - : RunProcessDirect("stat", ["-c", "%b", "--", linuxPath], null, 10000); + ? RunProcessShell($"stat -c='%b' {QuoteSingle(linuxPath)}", workingDir: null, 10000) + : RunProcessDirect("stat", ["-c='%b'", linuxPath], workingDir: null, 10000); - if (ok1 && long.TryParse(out1.Trim(), out long blocks)) + if (ok && long.TryParse(output.Trim(), out long blocks)) return (false, blocks * 512L); // st_blocks are always 512B units - var (ok2, out2, err2, code2) = RunProcessShell($"du -B1 -- {QuoteSingle(linuxPath)} | cut -f1", workingDir: null, 10000); // use shell for the pipe - - if (ok2 && long.TryParse(out2.Trim(), out long bytes)) - return (false, bytes); - - _logger.LogDebug("Btrfs size probe failed for {linux} (stat {code1}, du {code2}). Falling back to Length.", - linuxPath, code1, code2); + _logger.LogDebug("Btrfs size probe failed for {linux} (stat {code}, err {err}). Falling back to Length.", linuxPath, code, err); return (false, fileInfo.Length); } catch (Exception ex) @@ -360,7 +358,7 @@ public sealed class FileCompactor : IDisposable } /// - /// Decompress an BTRFS File + /// Decompress an BTRFS File on Wine/Linux /// /// Path of the compressed file /// Decompressing state @@ -370,44 +368,55 @@ public sealed class FileCompactor : IDisposable { try { - var (winPath, linuxPath) = ResolvePathsForBtrfs(path); - bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + bool isWine = _dalamudUtilService?.IsWine ?? false; + string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; var opts = GetMountOptionsForPath(linuxPath); - if (opts.Contains("compress", StringComparison.OrdinalIgnoreCase)) + bool hasCompress = opts.Contains("compress", StringComparison.OrdinalIgnoreCase); + bool hasCompressForce = opts.Contains("compress-force", StringComparison.OrdinalIgnoreCase); + + if (hasCompressForce) { - _logger.LogWarning( - "Cannot safely decompress {file}: filesystem mounted with compression ({opts}). Remount with 'compress=no'.", - linuxPath, opts); + _logger.LogWarning("Cannot safely decompress {file}: mount options contains compress-force ({opts}).", linuxPath, opts); return false; } + if (hasCompress) + { + var setCmd = $"btrfs property set -- {QuoteDouble(linuxPath)} compression none"; + var (okSet, _, errSet, codeSet) = isWine + ? RunProcessShell(setCmd) + : RunProcessDirect("btrfs", ["property", "set", "--", linuxPath, "compression", "none"]); + + if (!okSet) + { + _logger.LogWarning("Failed to set 'compression none' on {file}, please check drive options (exit code is: {code}): {err}", linuxPath, codeSet, errSet); + return false; + } + _logger.LogTrace("Set per-file 'compression none' on {file}", linuxPath); + } + if (!IsBtrfsCompressedFile(linuxPath)) { - _logger.LogTrace("Btrfs: not compressed, skip {file}", linuxPath); + _logger.LogTrace("{file} is not compressed, skipping decompression completely", linuxPath); return true; } - if (!ProbeFileReadableForBtrfs(winPath, linuxPath)) - return false; - - // Rewrite file uncompressed - (bool ok, string stdout, string stderr, int code) = - isWindowsProc - ? RunProcessShell($"btrfs filesystem defragment -- {QuoteSingle(linuxPath)}") - : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", linuxPath]); + var (ok, stdout, stderr, code) = isWine + ? RunProcessShell($"btrfs filesystem defragment -- {QuoteDouble(linuxPath)}") + : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", linuxPath]); if (!ok) { - _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {stderr}", + _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit code is: {code}): {stderr}", linuxPath, code, stderr); return false; } if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs (decompress) output {file}: {out}", linuxPath, stdout.Trim()); + _logger.LogTrace("btrfs defragment output for {file}: {out}", linuxPath, stdout.Trim()); - _logger.LogInformation("Decompressed (rewritten) Btrfs file: {file}", linuxPath); + _logger.LogInformation("Decompressed (rewritten uncompressed) Btrfs file: {file}", linuxPath); return true; } catch (Exception ex) @@ -467,19 +476,19 @@ public sealed class FileCompactor : IDisposable /// Path that has to be converted /// Extra check if using the wine enviroment /// Converted path to be used in Linux - private static string ToLinuxPathIfWine(string path, bool isWine) + private string ToLinuxPathIfWine(string path, bool isWine, bool preferShell = true) { if (!isWine || !IsProbablyWine()) return path; if (path.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - return "/" + path[3..].Replace('\\', '/'); + return ("/" + path[3..].Replace('\\', '/')).Replace("//", "/", StringComparison.Ordinal); if (path.StartsWith("C:\\", StringComparison.OrdinalIgnoreCase)) { + const string usersPrefix = "C:\\Users\\"; var p = path.Replace('/', '\\'); - const string usersPrefix = "C:\\Users\\"; if (p.StartsWith(usersPrefix, StringComparison.OrdinalIgnoreCase)) { int afterUsers = usersPrefix.Length; @@ -493,48 +502,38 @@ public sealed class FileCompactor : IDisposable var linuxUser = Environment.GetEnvironmentVariable("USER") ?? Environment.UserName; home = "/home/" + linuxUser; } - // Join as Unix path - return (home.TrimEnd('/') + "/" + rel).Replace("//", "/", StringComparison.Ordinal); + return (home!.TrimEnd('/') + "/" + rel).Replace("//", "/", StringComparison.Ordinal); } } try { - var inner = "winepath -u " + "'" + path.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; - var psi = new ProcessStartInfo + (bool ok, string stdout, string stderr, int code) = preferShell + ? RunProcessShell($"winepath -u {QuoteSingle(path)}", timeoutMs: 5000, workingDir: "/") + : RunProcessDirect("winepath", ["-u", path], workingDir: "/", timeoutMs: 5000); + + if (ok) { - FileName = "/bin/bash", - Arguments = "-c " + "\"" + inner.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - using var proc = Process.Start(psi); - var outp = proc?.StandardOutput.ReadToEnd().Trim(); - try - { - proc?.WaitForExit(); - } - catch - { - /* Wine can throw here; ignore */ + var outp = (stdout ?? "").Trim(); + if (!string.IsNullOrEmpty(outp) && outp.StartsWith('/')) + return outp.Replace("//", "/", StringComparison.Ordinal); + } + else + { + _logger.LogTrace("winepath failed for {path} (exit {code}): {err}", path, code, stderr); } - if (!string.IsNullOrEmpty(outp) && outp.StartsWith("/", StringComparison.Ordinal)) - return outp; } - catch + catch (Exception ex) { - /* ignore and fall through the floor! */ + _logger.LogTrace(ex, "winepath invocation failed for {path}", path); } } - - return path.Replace('\\', '/'); + + return path.Replace('\\', '/').Replace("//", "/", StringComparison.Ordinal); } /// - /// Compress an WOF File + /// Compress an File using the WOF methods (NTFS) /// /// Path of the decompressed/normal file /// Compessing state @@ -585,7 +584,7 @@ public sealed class FileCompactor : IDisposable } /// - /// Checks if an File is compacted with WOF compression + /// Checks if an File is compacted with WOF compression (NTFS) /// /// Path of the file /// State of the file @@ -612,7 +611,7 @@ public sealed class FileCompactor : IDisposable } /// - /// Checks if an File is compacted any WOF compression with an WOF backing + /// Checks if an File is compacted any WOF compression with an WOF backing (NTFS) /// /// Path of the file /// State of the file, if its an external (no backing) and which algorithm if detected @@ -821,7 +820,8 @@ public sealed class FileCompactor : IDisposable CreateNoWindow = true }; if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; - // Use a Login shell so PATH includes /usr/sbin etc. AKA -lc + + // Use a Login shell so PATH includes /usr/sbin etc. AKA -lc for login shell psi.ArgumentList.Add("-lc"); psi.ArgumentList.Add(QuoteDouble(command)); EnsureUnixPathEnv(psi); @@ -1011,7 +1011,7 @@ public sealed class FileCompactor : IDisposable } /// - /// Ensures the Unix pathing to be included into the process + /// Ensures the Unix pathing to be included into the process start /// /// Process private static void EnsureUnixPathEnv(ProcessStartInfo psi) @@ -1034,10 +1034,8 @@ public sealed class FileCompactor : IDisposable if (!isWindowsProc) return (path, path); - // Prefer winepath -u; fall back to your existing mapper - var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(path)}", null, 5000); - var linux = (ok && !string.IsNullOrWhiteSpace(outp)) ? outp.Trim() - : ToLinuxPathIfWine(path, isWine: true); + var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(path)}", workingDir: null, 5000); + var linux = (ok && !string.IsNullOrWhiteSpace(outp)) ? outp.Trim() : ToLinuxPathIfWine(path, isWine: true); return (path, linux); } diff --git a/LightlessSync/Services/Compression/BatchFileFragService.cs b/LightlessSync/Services/Compactor/BatchFileFragService.cs similarity index 71% rename from LightlessSync/Services/Compression/BatchFileFragService.cs rename to LightlessSync/Services/Compactor/BatchFileFragService.cs index 16c05e1..b31919e 100644 --- a/LightlessSync/Services/Compression/BatchFileFragService.cs +++ b/LightlessSync/Services/Compactor/BatchFileFragService.cs @@ -1,9 +1,8 @@ using Microsoft.Extensions.Logging; -using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Channels; -namespace LightlessSync.Services.Compression +namespace LightlessSync.Services.Compactor { /// /// This batch service is made for the File Frag command, because of each file needing to use this command. @@ -19,13 +18,26 @@ namespace LightlessSync.Services.Compression private readonly TimeSpan _flushDelay; private readonly CancellationTokenSource _cts = new(); - public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25) + public delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable args, string? workingDir, int timeoutMs); + private readonly RunDirect _runDirect; + + public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs); + private readonly RunShell _runShell; + + public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null) { _useShell = useShell; _log = log; _batchSize = Math.Max(8, batchSize); _flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs)); _ch = Channel.CreateUnbounded<(string, TaskCompletionSource)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + + // require runners to be setup, wouldnt start otherwise + if (runDirect is null || runShell is null) + throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor"); + _runDirect = runDirect; + _runShell = runShell; + _worker = Task.Run(ProcessAsync, _cts.Token); } @@ -92,7 +104,7 @@ namespace LightlessSync.Services.Compression try { - var map = await RunBatchAsync(pending.Select(p => p.path)).ConfigureAwait(false); + var map = RunBatch(pending.Select(p => p.path)); foreach (var (path, tcs) in pending) { tcs.TrySetResult(map.TryGetValue(path, out var c) && c); @@ -124,75 +136,33 @@ namespace LightlessSync.Services.Compression /// Paths that are needed for the command building for the batch return /// Path of the file and if it went correctly /// Failing to start filefrag on the system if this exception is found - private async Task> RunBatchAsync(IEnumerable paths) + private Dictionary RunBatch(IEnumerable paths) { var list = paths.Distinct(StringComparer.Ordinal).ToList(); var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal); - ProcessStartInfo psi; + (bool ok, string stdout, string stderr, int code) res; + if (_useShell) { - var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle)); - psi = new ProcessStartInfo - { - FileName = "/bin/bash", - Arguments = "-lc " + QuoteDouble(inner), - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - - if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) - psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin"; - else - psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; + var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle)); + res = _runShell(inner, timeoutMs: 15000, workingDir: "/"); } else { - psi = new ProcessStartInfo - { - FileName = "filefrag", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - if (!psi.Environment.TryGetValue("PATH", out var p) || string.IsNullOrWhiteSpace(p)) - psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin"; - else - psi.Environment["PATH"] = "/usr/sbin:/usr/bin:/bin:" + p; - - psi.ArgumentList.Add("-v"); - psi.ArgumentList.Add("--"); + var args = new List { "-v" }; foreach (var path in list) - psi.ArgumentList.Add(path); + { + args.Add(' ' + path); + } + + res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000); } - using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start filefrag"); + if (!string.IsNullOrWhiteSpace(res.stderr)) + _log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim()); - var outTask = proc.StandardOutput.ReadToEndAsync(_cts.Token); - var errTask = proc.StandardError.ReadToEndAsync(_cts.Token); - - var timeout = TimeSpan.FromSeconds(15); - var combined = Task.WhenAll(outTask, errTask); - var finished = await Task.WhenAny(combined, Task.Delay(timeout, _cts.Token)).ConfigureAwait(false); - - if (finished != combined) - { - try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ } - try { await combined.ConfigureAwait(false); } catch { /* ignore */ } - } - - var stdout = outTask.IsCompletedSuccessfully ? await outTask.ConfigureAwait(false) : ""; - var stderr = errTask.IsCompletedSuccessfully ? await errTask.ConfigureAwait(false) : ""; - - if (!string.IsNullOrWhiteSpace(stderr)) - _log.LogTrace("filefrag stderr (batch): {err}", stderr.Trim()); - - ParseFilefrag(stdout, result); + ParseFilefrag(res.stdout, result); return result; } @@ -225,7 +195,6 @@ namespace LightlessSync.Services.Compression } private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; - private static string QuoteDouble(string s) => "\"" + s.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal).Replace("$", "\\$", StringComparison.Ordinal).Replace("`", "\\`", StringComparison.Ordinal) + "\""; /// /// Regex of the File Size return on the Linux/Wine systems, giving back the amount diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index d686a75..0645118 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1236,7 +1236,7 @@ public class SettingsUi : WindowMediatorSubscriberBase UIColors.Get("LightlessYellow")); } - if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); + if (!_cacheMonitor.StorageisNTFS) ImGui.BeginDisabled(); if (ImGui.Checkbox("Use file compactor", ref useFileCompactor)) { _configService.Current.UseCompactor = useFileCompactor; @@ -1281,20 +1281,20 @@ public class SettingsUi : WindowMediatorSubscriberBase UIColors.Get("LightlessYellow")); } - if (!_cacheMonitor.StorageIsBtrfs && !_cacheMonitor.StorageisNTFS) + if (!_cacheMonitor.StorageisNTFS) { ImGui.EndDisabled(); - ImGui.TextUnformatted("The file compactor is only available on BTRFS and NTFS drives."); + ImGui.TextUnformatted("The file compactor is only available NTFS drives, soon for btrfs."); } if (_cacheMonitor.StorageisNTFS) { - ImGui.TextUnformatted("The file compactor is running on NTFS Drive."); + ImGui.TextUnformatted("The file compactor detected an NTFS Drive."); } if (_cacheMonitor.StorageIsBtrfs) { - ImGui.TextUnformatted("The file compactor is running on Btrfs Drive."); + ImGui.TextUnformatted("The file compactor detected an Btrfs Drive."); } ImGuiHelpers.ScaledDummy(new Vector2(10, 10)); -- 2.49.1 From e8c546c128a45619b3100e0a4f2f46792976d490 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 11 Nov 2025 11:54:21 -0600 Subject: [PATCH 037/140] update lightless API pt 1 --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index bb92cd4..3500db9 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit bb92cd477d76f24fd28200ade00076bc77fe299d +Subproject commit 3500db98c20cb7dcfd5e326f05cc2f7dc6bbfcfd -- 2.49.1 From 25756561b9bd666a9cdb7b4fb9020a3d9ff8b266 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 11 Nov 2025 12:02:37 -0600 Subject: [PATCH 038/140] updating lightlessapi --- LightlessAPI | 2 +- LightlessSync/WebAPI/SignalR/ApiController.cs | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index 3500db9..0170ac3 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 3500db98c20cb7dcfd5e326f05cc2f7dc6bbfcfd +Subproject commit 0170ac377d7d2341c0d0e206ab871af22ac4767b diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index 15aef29..d2fddc5 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -2,6 +2,7 @@ using Dalamud.Utility; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.API.SignalR; @@ -610,5 +611,40 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL { throw new NotImplementedException(); } + + public Task UpdateChatPresence(ChatPresenceUpdateDto presence) + { + throw new NotImplementedException(); + } + + public Task Client_ChatReceive(ChatMessageDto message) + { + throw new NotImplementedException(); + } + + public Task> GetZoneChatChannels() + { + throw new NotImplementedException(); + } + + public Task> GetGroupChatChannels() + { + throw new NotImplementedException(); + } + + public Task SendChatMessage(ChatSendRequestDto request) + { + throw new NotImplementedException(); + } + + public Task ReportChatMessage(ChatReportSubmitDto request) + { + throw new NotImplementedException(); + } + + public Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) + { + throw new NotImplementedException(); + } } #pragma warning restore MA0040 \ No newline at end of file -- 2.49.1 From 4a256f78072eb3fbcbea8291d298352c692832b7 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 11 Nov 2025 12:03:18 -0600 Subject: [PATCH 039/140] update penumbra api --- PenumbraAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PenumbraAPI b/PenumbraAPI index 648b6fc..a2f8923 160000 --- a/PenumbraAPI +++ b/PenumbraAPI @@ -1 +1 @@ -Subproject commit 648b6fc2ce600a95ab2b2ced27e1639af2b04502 +Subproject commit a2f89235464ea6cc25bb933325e8724b73312aa6 -- 2.49.1 From 9c794137c1c64ca08336eee928ed060cb95089db Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 11 Nov 2025 12:43:09 -0600 Subject: [PATCH 040/140] changelog added --- LightlessSync/Changelog/changelog.yaml | 39 +++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml index 7055a90..43b0c79 100644 --- a/LightlessSync/Changelog/changelog.yaml +++ b/LightlessSync/Changelog/changelog.yaml @@ -1,11 +1,42 @@ -tagline: "Lightless Sync v1.12.3" -subline: "LightSpeed, Welcome Screen, and More!" +tagline: "Lightless Sync v1.12.4" +subline: "Bugfixes and various improvements across Lightless" changelog: + - name: "v1.12.4" + tagline: "Preparation for future features" + date: "November 11th 2025" + # be sure to set this every new version + isCurrent: true + versions: + - number: "Syncshells" + icon: "" + items: + - "Added a pause button for syncshells in grouped folders" + - number: "Notifications" + icon: "" + items: + - "Fixed download notifications getting stuck at times" + - "Added more offset positions for the notifications" + - number: "Lightfinder" + icon: "" + items: + - "Pair button will now show up when you're not directly paired. ie. You are technically paired in a syncshell, but you may not be directly paired'" + - "Fixed a problem where the number of LightFinder users were cached, displaying the wrong information" + - "When LightFinder is enabled, if you hover over the number of users, it will show you how many users are also using LightFinder in your area" + - + - number: "Bugfixes" + icon: "" + items: + - "Added even more checks to nameplate handler to help not only us debug, but also other plugins writing to the plate" + - number: "Miscellaneous Changes" + icon: "" + items: + - "Default Linux to Websockets" + - "Revised Brio warning" + - "Added /lightless command to open Lightless UI (you can still use /light)" + - "Initial groundwork for future features" - name: "v1.12.3" tagline: "LightSpeed, Welcome Screen, and More!" date: "October 15th 2025" - # be sure to set this every new version - isCurrent: true versions: - number: "LightSpeed" icon: "" -- 2.49.1 From ef592032b346c57302deb3f00679a095c48d41d8 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 25 Nov 2025 07:14:59 +0900 Subject: [PATCH 041/140] init 2 --- .gitmodules | 15 +- LightlessSync.sln | 73 +- LightlessSync/FileCache/FileCacheManager.cs | 2 + .../FileCache/TransientResourceManager.cs | 191 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 251 +- .../Interop/Ipc/TextureConversionJob.cs | 21 + .../ChatConfigService.cs | 14 + .../Configurations/ChatConfig.cs | 15 + .../Configurations/LightlessConfig.cs | 2 + .../Configurations/PlayerPerformanceConfig.cs | 6 + LightlessSync/LightlessSync.csproj | 20 +- .../Data/FileReplacementDataComparer.cs | 46 +- .../Factories/FileDownloadManagerFactory.cs | 21 +- .../Factories/GameObjectHandlerFactory.cs | 23 +- .../PlayerData/Factories/PairFactory.cs | 71 +- .../Factories/PairHandlerFactory.cs | 55 - .../PlayerData/Handlers/PairHandler.cs | 775 ----- .../Pairs/IPairPerformanceSubject.cs | 16 + LightlessSync/PlayerData/Pairs/Pair.cs | 239 +- .../PlayerData/Pairs/PairCoordinator.cs | 553 ++++ .../PlayerData/Pairs/PairHandlerAdapter.cs | 1835 +++++++++++ .../PlayerData/Pairs/PairHandlerRegistry.cs | 493 +++ LightlessSync/PlayerData/Pairs/PairLedger.cs | 293 ++ LightlessSync/PlayerData/Pairs/PairManager.cs | 944 +++--- LightlessSync/PlayerData/Pairs/PairState.cs | 149 + .../PlayerData/Pairs/PairStateCache.cs | 118 + .../Pairs/VisibleUserDataDistributor.cs | 190 +- .../Services/CacheCreationService.cs | 33 +- LightlessSync/Plugin.cs | 105 +- .../ActorTracking/ActorObjectService.cs | 754 +++++ .../Services/BroadcastScanningService.cs | 20 +- .../Services/CharaData/CharaDataManager.cs | 11 +- LightlessSync/Services/CharacterAnalyzer.cs | 52 +- LightlessSync/Services/Chat/ChatModels.cs | 23 + .../Services/Chat/ZoneChatService.cs | 1131 +++++++ LightlessSync/Services/ContextMenuService.cs | 125 +- LightlessSync/Services/DalamudUtilService.cs | 102 +- .../Services/LightlessGroupProfileData.cs | 20 +- .../Services/LightlessProfileData.cs | 20 + .../Services/LightlessProfileManager.cs | 179 +- .../Services/LightlessUserProfileData.cs | 21 +- LightlessSync/Services/Mediator/Messages.cs | 23 +- LightlessSync/Services/NameplateHandler.cs | 20 +- LightlessSync/Services/NameplateService.cs | 13 +- LightlessSync/Services/NotificationService.cs | 33 +- LightlessSync/Services/PairRequestService.cs | 79 +- .../Services/PlayerPerformanceService.cs | 129 +- .../ServerConfigurationManager.cs | 7 + .../TextureCompression/TexFileHelper.cs | 282 ++ .../TextureCompressionCapabilities.cs | 58 + .../TextureCompressionRequest.cs | 8 + .../TextureCompressionService.cs | 330 ++ .../TextureCompressionTarget.cs | 10 + .../TextureDownscaleService.cs | 955 ++++++ .../TextureCompression/TextureMapKind.cs | 13 + .../TextureMetadataHelper.cs | 549 ++++ .../TextureUsageCategory.cs | 16 + LightlessSync/Services/UiFactory.cs | 116 +- LightlessSync/Services/UiService.cs | 106 +- LightlessSync/UI/BroadcastUI.cs | 10 +- LightlessSync/UI/CharaDataHubUi.McdOnline.cs | 7 +- LightlessSync/UI/CharaDataHubUi.cs | 9 +- LightlessSync/UI/CompactUI.cs | 384 ++- LightlessSync/UI/Components/DrawFolderBase.cs | 63 +- .../UI/Components/DrawFolderGroup.cs | 21 +- LightlessSync/UI/Components/DrawFolderTag.cs | 182 +- .../UI/Components/DrawGroupedGroupFolder.cs | 44 +- LightlessSync/UI/Components/DrawUserPair.cs | 74 +- .../Components/Popup/BanUserPopupHandler.cs | 7 +- .../UI/Components/SelectPairForTagUi.cs | 2 +- .../UI/Components/SelectSyncshellForTagUi.cs | 2 +- LightlessSync/UI/DataAnalysisUi.cs | 2913 +++++++++++++---- LightlessSync/UI/DrawEntityFactory.cs | 188 +- LightlessSync/UI/DtrEntry.cs | 18 +- LightlessSync/UI/EditProfileUi.Group.cs | 701 ++++ LightlessSync/UI/EditProfileUi.cs | 1453 ++++++-- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 163 +- LightlessSync/UI/Models/PairDisplayEntry.cs | 25 + LightlessSync/UI/Models/PairUiEntry.cs | 30 + LightlessSync/UI/Models/PairUiSnapshot.cs | 24 + .../UI/Models/VisiblePairSortMode.cs | 11 + LightlessSync/UI/PopoutProfileUi.cs | 15 +- .../UI/ProfileEditorLayoutCoordinator.cs | 84 + LightlessSync/UI/ProfileTags.cs | 25 +- LightlessSync/UI/Services/PairUiService.cs | 228 ++ LightlessSync/UI/SettingsUi.cs | 253 +- LightlessSync/UI/StandaloneProfileUi.cs | 1263 ++++++- LightlessSync/UI/Style/MainStyle.cs | 2 +- LightlessSync/UI/Style/Selune.cs | 1006 ++++++ LightlessSync/UI/SyncshellAdminUI.cs | 262 +- LightlessSync/UI/SyncshellFinderUI.cs | 14 +- LightlessSync/UI/Tags/ProfileTagDefinition.cs | 30 + LightlessSync/UI/Tags/ProfileTagRenderer.cs | 226 ++ LightlessSync/UI/Tags/ProfileTagService.cs | 131 + LightlessSync/UI/TopTabMenu.cs | 137 +- LightlessSync/UI/UIColors.cs | 5 + LightlessSync/UI/UISharedService.cs | 128 +- LightlessSync/UI/ZoneChatUi.cs | 1101 +++++++ LightlessSync/Utils/Crypto.cs | 26 +- LightlessSync/Utils/SeStringUtils.cs | 440 +++ LightlessSync/Utils/VariousExtensions.cs | 24 +- .../WebAPI/Files/FileDownloadManager.cs | 90 +- .../WebAPI/Files/FileTransferOrchestrator.cs | 140 +- .../WebAPI/Files/FileUploadManager.cs | 1 + .../SignalR/ApIController.Functions.Users.cs | 33 +- .../ApiController.Functions.Callbacks.cs | 48 +- LightlessSync/WebAPI/SignalR/ApiController.cs | 231 +- LightlessSync/lib/DirectXTexC.dll | Bin 0 -> 983552 bytes LightlessSync/lib/OtterTex.dll | Bin 0 -> 42496 bytes LightlessSync/packages.lock.json | 49 +- PenumbraAPI | 1 - 111 files changed, 20622 insertions(+), 3476 deletions(-) create mode 100644 LightlessSync/Interop/Ipc/TextureConversionJob.cs create mode 100644 LightlessSync/LightlessConfiguration/ChatConfigService.cs create mode 100644 LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs delete mode 100644 LightlessSync/PlayerData/Factories/PairHandlerFactory.cs delete mode 100644 LightlessSync/PlayerData/Handlers/PairHandler.cs create mode 100644 LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairCoordinator.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairLedger.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairState.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairStateCache.cs create mode 100644 LightlessSync/Services/ActorTracking/ActorObjectService.cs create mode 100644 LightlessSync/Services/Chat/ChatModels.cs create mode 100644 LightlessSync/Services/Chat/ZoneChatService.cs create mode 100644 LightlessSync/Services/LightlessProfileData.cs create mode 100644 LightlessSync/Services/TextureCompression/TexFileHelper.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureCompressionService.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureCompressionTarget.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureDownscaleService.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureMapKind.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs create mode 100644 LightlessSync/Services/TextureCompression/TextureUsageCategory.cs create mode 100644 LightlessSync/UI/EditProfileUi.Group.cs create mode 100644 LightlessSync/UI/Models/PairDisplayEntry.cs create mode 100644 LightlessSync/UI/Models/PairUiEntry.cs create mode 100644 LightlessSync/UI/Models/PairUiSnapshot.cs create mode 100644 LightlessSync/UI/Models/VisiblePairSortMode.cs create mode 100644 LightlessSync/UI/ProfileEditorLayoutCoordinator.cs create mode 100644 LightlessSync/UI/Services/PairUiService.cs create mode 100644 LightlessSync/UI/Style/Selune.cs create mode 100644 LightlessSync/UI/Tags/ProfileTagDefinition.cs create mode 100644 LightlessSync/UI/Tags/ProfileTagRenderer.cs create mode 100644 LightlessSync/UI/Tags/ProfileTagService.cs create mode 100644 LightlessSync/UI/ZoneChatUi.cs create mode 100644 LightlessSync/lib/DirectXTexC.dll create mode 100644 LightlessSync/lib/OtterTex.dll delete mode 160000 PenumbraAPI diff --git a/.gitmodules b/.gitmodules index fe64c7f..7879cd2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,15 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git -[submodule "PenumbraAPI"] - path = PenumbraAPI - url = https://github.com/Ottermandias/Penumbra.Api.git +[submodule "Penumbra.GameData"] + path = Penumbra.GameData + url = https://github.com/Ottermandias/Penumbra.GameData +[submodule "Penumbra.Api"] + path = Penumbra.Api + url = https://github.com/Ottermandias/Penumbra.Api +[submodule "Penumbra.String"] + path = Penumbra.String + url = https://github.com/Ottermandias/Penumbra.String +[submodule "OtterGui"] + path = OtterGui + url = https://github.com/Ottermandias/OtterGui diff --git a/LightlessSync.sln b/LightlessSync.sln index 5b7ca3c..6aba7e9 100644 --- a/LightlessSync.sln +++ b/LightlessSync.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32328.378 @@ -12,7 +11,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "PenumbraAPI\Penumbra.Api.csproj", "{C104F6BE-9CC4-9CF7-271C-5C3A1F646601}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{65ACC53A-1D72-40D4-A99E-7D451D87E182}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{719723E1-8218-495A-98BA-4D0BDF7822EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OtterGui", "OtterGui", "{F30CFB00-531B-5698-C50F-38FBF3471340}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGuiInternal", "OtterGui\OtterGuiInternal\OtterGuiInternal.csproj", "{DF590F45-F26C-4337-98DE-367C97253125}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -22,34 +31,70 @@ Global Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Release|x64 - {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Release|x64 + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64 + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.ActiveCfg = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.ActiveCfg = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.ActiveCfg = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = Release|x64 - {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Release|Any CPU - {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Release|Any CPU + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.ActiveCfg = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.ActiveCfg = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = Release|Any CPU - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.ActiveCfg = Debug|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|Any CPU.Build.0 = Debug|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.ActiveCfg = Debug|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Debug|x64.Build.0 = Debug|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.ActiveCfg = Release|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|Any CPU.Build.0 = Release|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.ActiveCfg = Release|x64 - {C104F6BE-9CC4-9CF7-271C-5C3A1F646601}.Release|x64.Build.0 = Release|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.ActiveCfg = Debug|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.Build.0 = Debug|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.ActiveCfg = Debug|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.Build.0 = Debug|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.ActiveCfg = Release|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.Build.0 = Release|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.ActiveCfg = Release|x64 + {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.Build.0 = Release|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.ActiveCfg = Debug|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.Build.0 = Debug|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.ActiveCfg = Debug|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.Build.0 = Debug|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.ActiveCfg = Release|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.Build.0 = Release|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.ActiveCfg = Release|x64 + {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.Build.0 = Release|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.ActiveCfg = Debug|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.Build.0 = Debug|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.ActiveCfg = Debug|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.Build.0 = Debug|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.ActiveCfg = Release|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.Build.0 = Release|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.ActiveCfg = Release|x64 + {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.Build.0 = Release|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.ActiveCfg = Debug|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.Build.0 = Debug|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.ActiveCfg = Debug|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.Build.0 = Debug|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.ActiveCfg = Release|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.Build.0 = Release|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.ActiveCfg = Release|x64 + {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.Build.0 = Release|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.ActiveCfg = Debug|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.Build.0 = Debug|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.ActiveCfg = Debug|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.Build.0 = Debug|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.ActiveCfg = Release|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.Build.0 = Release|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.ActiveCfg = Release|x64 + {DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {719723E1-8218-495A-98BA-4D0BDF7822EB} = {F30CFB00-531B-5698-C50F-38FBF3471340} + {DF590F45-F26C-4337-98DE-367C97253125} = {F30CFB00-531B-5698-C50F-38FBF3471340} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF} EndGlobalSection diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index 7ee6c99..cda255c 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -823,6 +823,8 @@ public sealed class FileCacheManager : IHostedService _logger.LogInformation("Started FileCacheManager"); + _lightlessMediator.Publish(new FileCacheInitializedMessage()); + return Task.CompletedTask; } diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 6a9575a..f808fa6 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -3,11 +3,17 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.PlayerData.Data; using LightlessSync.PlayerData.Handlers; +using LightlessSync.PlayerData.Factories; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Linq; +using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; namespace LightlessSync.FileCache; @@ -17,21 +23,29 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private readonly HashSet _cachedHandledPaths = new(StringComparer.Ordinal); private readonly TransientConfigService _configurationService; private readonly DalamudUtilService _dalamudUtil; + private readonly ActorObjectService _actorObjectService; + private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; + private readonly object _ownedHandlerLock = new(); private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; private readonly string[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"]; private readonly HashSet _playerRelatedPointers = []; - private ConcurrentDictionary _cachedFrameAddresses = []; + private readonly Dictionary _ownedHandlers = new(); + private ConcurrentDictionary _cachedFrameAddresses = new(); private ConcurrentDictionary>? _semiTransientResources = null; private uint _lastClassJobId = uint.MaxValue; public bool IsTransientRecording { get; private set; } = false; public TransientResourceManager(ILogger logger, TransientConfigService configurationService, - DalamudUtilService dalamudUtil, LightlessMediator mediator) : base(logger, mediator) + DalamudUtilService dalamudUtil, LightlessMediator mediator, ActorObjectService actorObjectService, GameObjectHandlerFactory gameObjectHandlerFactory) : base(logger, mediator) { _configurationService = configurationService; _dalamudUtil = dalamudUtil; + _actorObjectService = actorObjectService; + _gameObjectHandlerFactory = gameObjectHandlerFactory; Mediator.Subscribe(this, Manager_PenumbraResourceLoadEvent); + Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor)); + Mediator.Subscribe(this, msg => HandleActorUntracked(msg.Descriptor)); Mediator.Subscribe(this, (_) => Manager_PenumbraModSettingChanged()); Mediator.Subscribe(this, (_) => DalamudUtil_FrameworkUpdate()); Mediator.Subscribe(this, (msg) => @@ -44,6 +58,11 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (!msg.OwnedObject) return; _playerRelatedPointers.Remove(msg.GameObjectHandler); }); + + foreach (var descriptor in _actorObjectService.PlayerDescriptors) + { + HandleActorTracked(descriptor); + } } private TransientConfig.TransientPlayerConfig PlayerConfig @@ -241,16 +260,46 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase TransientResources.Clear(); SemiTransientResources.Clear(); + + lock (_ownedHandlerLock) + { + foreach (var handler in _ownedHandlers.Values) + { + handler.Dispose(); + } + _ownedHandlers.Clear(); + } } private void DalamudUtil_FrameworkUpdate() { - _cachedFrameAddresses = new(_playerRelatedPointers.Where(k => k.Address != nint.Zero).ToDictionary(c => c.Address, c => c.ObjectKind)); lock (_cacheAdditionLock) { _cachedHandledPaths.Clear(); } + var activeDescriptors = new Dictionary(); + foreach (var descriptor in _actorObjectService.PlayerDescriptors) + { + if (TryResolveObjectKind(descriptor, out var resolvedKind)) + { + activeDescriptors[descriptor.Address] = resolvedKind; + } + } + + foreach (var address in _cachedFrameAddresses.Keys.ToList()) + { + if (!activeDescriptors.ContainsKey(address)) + { + _cachedFrameAddresses.TryRemove(address, out _); + } + } + + foreach (var descriptor in activeDescriptors) + { + _cachedFrameAddresses[descriptor.Key] = descriptor.Value; + } + if (_lastClassJobId != _dalamudUtil.ClassJobId) { _lastClassJobId = _dalamudUtil.ClassJobId; @@ -259,16 +308,15 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase value?.Clear(); } - // reload config for current new classjob PlayerConfig.JobSpecificCache.TryGetValue(_dalamudUtil.ClassJobId, out var jobSpecificData); SemiTransientResources[ObjectKind.Player] = PlayerConfig.GlobalPersistentCache.Concat(jobSpecificData ?? []).ToHashSet(StringComparer.OrdinalIgnoreCase); PlayerConfig.JobSpecificPetCache.TryGetValue(_dalamudUtil.ClassJobId, out var petSpecificData); SemiTransientResources[ObjectKind.Pet] = [.. petSpecificData ?? []]; } - foreach (var kind in Enum.GetValues(typeof(ObjectKind))) + foreach (var kind in Enum.GetValues(typeof(ObjectKind)).Cast()) { - if (!_cachedFrameAddresses.Any(k => k.Value == (ObjectKind)kind) && TransientResources.Remove((ObjectKind)kind, out _)) + if (!_cachedFrameAddresses.Any(k => k.Value == kind) && TransientResources.Remove(kind, out _)) { Logger.LogDebug("Object not present anymore: {kind}", kind.ToString()); } @@ -292,6 +340,116 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _semiTransientResources = null; } + private static bool TryResolveObjectKind(ActorObjectService.ActorDescriptor descriptor, out ObjectKind resolvedKind) + { + if (descriptor.OwnedKind is ObjectKind ownedKind) + { + resolvedKind = ownedKind; + return true; + } + + if (descriptor.ObjectKind == DalamudObjectKind.Player) + { + resolvedKind = ObjectKind.Player; + return true; + } + + resolvedKind = default; + return false; + } + + private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) + { + if (!TryResolveObjectKind(descriptor, out var resolvedKind)) + return; + + if (Logger.IsEnabled(LogLevel.Debug)) + { + Logger.LogDebug("ActorObject tracked: {kind} addr={address:X} name={name}", resolvedKind, descriptor.Address, descriptor.Name); + } + + _cachedFrameAddresses[descriptor.Address] = resolvedKind; + + if (descriptor.OwnedKind is not ObjectKind ownedKind) + return; + + lock (_ownedHandlerLock) + { + if (_ownedHandlers.ContainsKey(descriptor.Address)) + return; + + _ = CreateOwnedHandlerAsync(descriptor, ownedKind); + } + } + + private void HandleActorUntracked(ActorObjectService.ActorDescriptor descriptor) + { + if (Logger.IsEnabled(LogLevel.Debug)) + { + var kindLabel = descriptor.OwnedKind?.ToString() + ?? (descriptor.ObjectKind == DalamudObjectKind.Player ? ObjectKind.Player.ToString() : ""); + Logger.LogDebug("ActorObject untracked: addr={address:X} name={name} kind={kind}", descriptor.Address, descriptor.Name, kindLabel); + } + + _cachedFrameAddresses.TryRemove(descriptor.Address, out _); + + if (descriptor.OwnedKind is not ObjectKind) + return; + + lock (_ownedHandlerLock) + { + if (_ownedHandlers.Remove(descriptor.Address, out var handler)) + { + handler.Dispose(); + } + } + } + + private async Task CreateOwnedHandlerAsync(ActorObjectService.ActorDescriptor descriptor, ObjectKind kind) + { + try + { + var handler = await _gameObjectHandlerFactory.Create( + kind, + () => + { + if (!string.IsNullOrEmpty(descriptor.HashedContentId) && + _actorObjectService.TryGetActorByHash(descriptor.HashedContentId, out var current) && + current.OwnedKind == kind) + { + return current.Address; + } + + return descriptor.Address; + }, + true).ConfigureAwait(false); + + if (handler.Address == IntPtr.Zero) + { + handler.Dispose(); + return; + } + + lock (_ownedHandlerLock) + { + if (!_cachedFrameAddresses.ContainsKey(descriptor.Address)) + { + Logger.LogDebug("ActorObject handler discarded (stale): addr={address:X}", descriptor.Address); + handler.Dispose(); + return; + } + + _ownedHandlers[descriptor.Address] = handler; + } + + Logger.LogDebug("ActorObject handler created: {kind} addr={address:X}", kind, descriptor.Address); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to create owned handler for {kind} at {address:X}", kind, descriptor.Address); + } + } + private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg) { var gamePath = msg.GamePath.ToLowerInvariant(); @@ -383,21 +541,30 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void SendTransients(nint gameObject, ObjectKind objectKind) { + _sendTransientCts.Cancel(); + _sendTransientCts = new(); + var token = _sendTransientCts.Token; + _ = Task.Run(async () => { - _sendTransientCts?.Cancel(); - _sendTransientCts?.Dispose(); - _sendTransientCts = new(); - var token = _sendTransientCts.Token; - await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false); - foreach (var kvp in TransientResources) + try { + await Task.Delay(TimeSpan.FromSeconds(5), token).ConfigureAwait(false); + if (TransientResources.TryGetValue(objectKind, out var values) && values.Any()) { Logger.LogTrace("Sending Transients for {kind}", objectKind); Mediator.Publish(new TransientResourceChangedMessage(gameObject)); } } + catch (TaskCanceledException) + { + + } + catch (System.OperationCanceledException) + { + + } }); } diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index fd92fca..2ecc56b 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -2,12 +2,18 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace LightlessSync.Interop.Ipc; @@ -17,6 +23,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa private readonly DalamudUtilService _dalamudUtil; private readonly LightlessMediator _lightlessMediator; private readonly RedrawManager _redrawManager; + private readonly ActorObjectService _actorObjectService; private bool _shownPenumbraUnavailable = false; private string? _penumbraModDirectory; public string? ModDirectory @@ -33,6 +40,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa } private readonly ConcurrentDictionary _penumbraRedrawRequests = new(); + private readonly ConcurrentDictionary _trackedActors = new(); private readonly EventSubscriber _penumbraDispose; private readonly EventSubscriber _penumbraGameObjectResourcePathResolved; @@ -52,14 +60,19 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa private readonly GetModDirectory _penumbraResolveModDir; private readonly ResolvePlayerPathsAsync _penumbraResolvePaths; private readonly GetGameObjectResourcePaths _penumbraResourcePaths; + //private readonly GetPlayerResourcePaths _penumbraPlayerResourcePaths; + private readonly GetCollections _penumbraGetCollections; + private readonly ConcurrentDictionary _activeTemporaryCollections = new(); + private int _performedInitialCleanup; public IpcCallerPenumbra(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, - LightlessMediator lightlessMediator, RedrawManager redrawManager) : base(logger, lightlessMediator) + LightlessMediator lightlessMediator, RedrawManager redrawManager, ActorObjectService actorObjectService) : base(logger, lightlessMediator) { _pi = pi; _dalamudUtil = dalamudUtil; _lightlessMediator = lightlessMediator; _redrawManager = redrawManager; + _actorObjectService = actorObjectService; _penumbraInit = Initialized.Subscriber(pi, PenumbraInit); _penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose); _penumbraResolveModDir = new GetModDirectory(pi); @@ -71,6 +84,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa _penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi); _penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi); _penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi); + _penumbraGetCollections = new GetCollections(pi); _penumbraResolvePaths = new ResolvePlayerPathsAsync(pi); _penumbraEnabled = new GetEnabledState(pi); _penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) => @@ -80,6 +94,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa }); _penumbraConvertTextureFile = new ConvertTextureFile(pi); _penumbraResourcePaths = new GetGameObjectResourcePaths(pi); + //_penumbraPlayerResourcePaths = new GetPlayerResourcePaths(pi); _penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded); @@ -92,6 +107,46 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa }); Mediator.Subscribe(this, (msg) => _shownPenumbraUnavailable = false); + + Mediator.Subscribe(this, msg => + { + if (msg.Descriptor.Address != nint.Zero) + { + _trackedActors[(IntPtr)msg.Descriptor.Address] = 0; + } + }); + + Mediator.Subscribe(this, msg => + { + if (msg.Descriptor.Address != nint.Zero) + { + _trackedActors.TryRemove((IntPtr)msg.Descriptor.Address, out _); + } + }); + + Mediator.Subscribe(this, msg => + { + if (msg.GameObjectHandler.Address != nint.Zero) + { + _trackedActors[(IntPtr)msg.GameObjectHandler.Address] = 0; + } + }); + + Mediator.Subscribe(this, msg => + { + if (msg.GameObjectHandler.Address != nint.Zero) + { + _trackedActors.TryRemove((IntPtr)msg.GameObjectHandler.Address, out _); + } + }); + + foreach (var descriptor in _actorObjectService.PlayerDescriptors) + { + if (descriptor.Address != nint.Zero) + { + _trackedActors[(IntPtr)descriptor.Address] = 0; + } + } } public bool APIAvailable { get; private set; } = false; @@ -130,6 +185,11 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa NotificationType.Error)); } } + + if (APIAvailable) + { + ScheduleTemporaryCollectionCleanup(); + } } public void CheckModDirectory() @@ -144,6 +204,56 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa } } + private void ScheduleTemporaryCollectionCleanup() + { + if (Interlocked.Exchange(ref _performedInitialCleanup, 1) != 0) + return; + + _ = Task.Run(CleanupTemporaryCollectionsAsync); + } + + private async Task CleanupTemporaryCollectionsAsync() + { + if (!APIAvailable) + return; + + try + { + var collections = await _dalamudUtil.RunOnFrameworkThread(() => _penumbraGetCollections.Invoke()).ConfigureAwait(false); + foreach (var (collectionId, name) in collections) + { + if (!IsLightlessCollectionName(name)) + continue; + + if (_activeTemporaryCollections.ContainsKey(collectionId)) + continue; + + Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); + var deleteResult = await _dalamudUtil.RunOnFrameworkThread(() => + { + var result = (PenumbraApiEc)_penumbraRemoveTemporaryCollection.Invoke(collectionId); + Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); + return result; + }).ConfigureAwait(false); + if (deleteResult == PenumbraApiEc.Success) + { + _activeTemporaryCollections.TryRemove(collectionId, out _); + } + else + { + Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); + } + } + + private static bool IsLightlessCollectionName(string? name) + => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); + protected override void Dispose(bool disposing) { base.Dispose(disposing); @@ -169,58 +279,91 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa }).ConfigureAwait(false); } - public async Task ConvertTextureFiles(ILogger logger, Dictionary textures, IProgress<(string, int)> progress, CancellationToken token) + public async Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) { - if (!APIAvailable) return; + if (!APIAvailable || jobs.Count == 0) + { + return; + } _lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles))); - int currentTexture = 0; - foreach (var texture in textures) + + var totalJobs = jobs.Count; + var completedJobs = 0; + + try { - if (token.IsCancellationRequested) break; - - progress.Report((texture.Key, ++currentTexture)); - - logger.LogInformation("Converting Texture {path} to {type}", texture.Key, TextureType.Bc7Tex); - var convertTask = _penumbraConvertTextureFile.Invoke(texture.Key, texture.Key, TextureType.Bc7Tex, mipMaps: true); - await convertTask.ConfigureAwait(false); - if (convertTask.IsCompletedSuccessfully && texture.Value.Any()) + foreach (var job in jobs) { - foreach (var duplicatedTexture in texture.Value) + if (token.IsCancellationRequested) { - logger.LogInformation("Migrating duplicate {dup}", duplicatedTexture); - try + break; + } + + progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job)); + + logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType); + var convertTask = _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps); + await convertTask.ConfigureAwait(false); + + if (convertTask.IsCompletedSuccessfully && job.DuplicateTargets is { Count: > 0 }) + { + foreach (var duplicate in job.DuplicateTargets) { - File.Copy(texture.Key, duplicatedTexture, overwrite: true); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to copy duplicate {dup}", duplicatedTexture); + logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate); + try + { + File.Copy(job.OutputFile, duplicate, overwrite: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate); + } } } + + completedJobs++; } } - _lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles))); - - await _dalamudUtil.RunOnFrameworkThread(async () => + finally { - var gameObject = await _dalamudUtil.CreateGameObjectAsync(await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false)).ConfigureAwait(false); - _penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw); - }).ConfigureAwait(false); + _lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles))); + } + + if (completedJobs > 0 && !token.IsCancellationRequested) + { + await _dalamudUtil.RunOnFrameworkThread(async () => + { + var player = await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false); + if (player == null) + { + return; + } + + var gameObject = await _dalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false); + _penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw); + }).ConfigureAwait(false); + } } public async Task CreateTemporaryCollectionAsync(ILogger logger, string uid) { if (!APIAvailable) return Guid.Empty; - return await _dalamudUtil.RunOnFrameworkThread(() => + var (collectionId, collectionName) = await _dalamudUtil.RunOnFrameworkThread(() => { var collName = "Lightless_" + uid; _penumbraCreateNamedTemporaryCollection.Invoke(collName, collName, out var collId); logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId); - return collId; + return (collId, collName); }).ConfigureAwait(false); + if (collectionId != Guid.Empty) + { + _activeTemporaryCollections[collectionId] = collectionName; + } + + return collectionId; } public async Task>?> GetCharacterData(ILogger logger, GameObjectHandler handler) @@ -270,6 +413,10 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa var ret2 = _penumbraRemoveTemporaryCollection.Invoke(collId); logger.LogTrace("[{applicationId}] RemoveTemporaryCollection: {ret2}", applicationId, ret2); }).ConfigureAwait(false); + if (collId != Guid.Empty) + { + _activeTemporaryCollections.TryRemove(collId, out _); + } } public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) @@ -277,6 +424,31 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa return await _penumbraResolvePaths.Invoke(forward, reverse).ConfigureAwait(false); } + public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) + { + if (!APIAvailable) return; + + token.ThrowIfCancellationRequested(); + + await _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps) + .ConfigureAwait(false); + + if (job.DuplicateTargets is { Count: > 0 }) + { + foreach (var duplicate in job.DuplicateTargets) + { + try + { + File.Copy(job.OutputFile, duplicate, overwrite: true); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to copy duplicate {Duplicate} for texture conversion", duplicate); + } + } + } + } + public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collId, string manipulationData) { if (!APIAvailable) return; @@ -321,10 +493,26 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa private void ResourceLoaded(IntPtr ptr, string arg1, string arg2) { - if (ptr != IntPtr.Zero && string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) != 0) + if (ptr == IntPtr.Zero) + return; + + if (!_trackedActors.ContainsKey(ptr)) { - _lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2)); + var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr); + if (descriptor.Address != nint.Zero) + { + _trackedActors[ptr] = 0; + } + else + { + return; + } } + + if (string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) == 0) + return; + + _lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2)); } private void PenumbraDispose() @@ -338,6 +526,7 @@ public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCa APIAvailable = true; ModDirectory = _penumbraResolveModDir.Invoke(); _lightlessMediator.Publish(new PenumbraInitializedMessage()); + ScheduleTemporaryCollectionCleanup(); _penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw); } } diff --git a/LightlessSync/Interop/Ipc/TextureConversionJob.cs b/LightlessSync/Interop/Ipc/TextureConversionJob.cs new file mode 100644 index 0000000..2bbe9fd --- /dev/null +++ b/LightlessSync/Interop/Ipc/TextureConversionJob.cs @@ -0,0 +1,21 @@ +using Penumbra.Api.Enums; + +namespace LightlessSync.Interop.Ipc; + +/// +/// Represents a single texture conversion request, including optional duplicate targets. +/// +public sealed record TextureConversionJob( + string InputFile, + string OutputFile, + TextureType TargetType, + bool IncludeMipMaps = true, + IReadOnlyList? DuplicateTargets = null); + +/// +/// Progress payload for a texture conversion batch. +/// +/// Number of completed conversions. +/// Total number of conversions scheduled. +/// The job currently being processed. +public sealed record TextureConversionProgress(int Completed, int Total, TextureConversionJob CurrentJob); diff --git a/LightlessSync/LightlessConfiguration/ChatConfigService.cs b/LightlessSync/LightlessConfiguration/ChatConfigService.cs new file mode 100644 index 0000000..f91cfca --- /dev/null +++ b/LightlessSync/LightlessConfiguration/ChatConfigService.cs @@ -0,0 +1,14 @@ +using LightlessSync.LightlessConfiguration.Configurations; + +namespace LightlessSync.LightlessConfiguration; + +public sealed class ChatConfigService : ConfigurationServiceBase +{ + public const string ConfigName = "chatconfig.json"; + + public ChatConfigService(string configDir) : base(configDir) + { + } + + public override string ConfigurationName => ConfigName; +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs new file mode 100644 index 0000000..7812887 --- /dev/null +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -0,0 +1,15 @@ +using System; + +namespace LightlessSync.LightlessConfiguration.Configurations; + +[Serializable] +public sealed class ChatConfig : ILightlessConfiguration +{ + public int Version { get; set; } = 1; + public bool AutoEnableChatOnLogin { get; set; } = false; + public bool ShowRulesOverlayOnOpen { get; set; } = true; + public bool ShowMessageTimestamps { get; set; } = true; + public float ChatWindowOpacity { get; set; } = .97f; + public bool IsWindowPinned { get; set; } = false; + public bool AutoOpenChatOnPluginLoad { get; set; } = false; +} diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 929cbbc..478db89 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -2,6 +2,7 @@ using Dalamud.Game.Text; using LightlessSync.UtilsEnum.Enum; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.UI; +using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; namespace LightlessSync.LightlessConfiguration.Configurations; @@ -48,6 +49,7 @@ public class LightlessConfig : ILightlessConfiguration public int DownloadSpeedLimitInBytes { get; set; } = 0; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; public bool PreferNotesOverNamesForVisible { get; set; } = false; + public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default; public float ProfileDelay { get; set; } = 1.5f; public bool ProfilePopoutRight { get; set; } = false; public bool ProfilesAllowNsfw { get; set; } = false; diff --git a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs index ca12006..7da9ac2 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs @@ -4,6 +4,7 @@ public class PlayerPerformanceConfig : ILightlessConfiguration { public int Version { get; set; } = 1; public bool ShowPerformanceIndicator { get; set; } = true; + public bool ShowPerformanceUsageNextToName { get; set; } = false; public bool WarnOnExceedingThresholds { get; set; } = true; public bool WarnOnPreferredPermissionsExceedingThresholds { get; set; } = false; public int VRAMSizeWarningThresholdMiB { get; set; } = 375; @@ -16,4 +17,9 @@ public class PlayerPerformanceConfig : ILightlessConfiguration public bool PauseInInstanceDuty { get; set; } = false; public bool PauseWhilePerforming { get; set; } = true; public bool PauseInCombat { get; set; } = true; + public bool EnableNonIndexTextureMipTrim { get; set; } = false; + public bool EnableIndexTextureDownscale { get; set; } = false; + public int TextureDownscaleMaxDimension { get; set; } = 2048; + public bool OnlyDownscaleUncompressedTextures { get; set; } = true; + public bool KeepOriginalTextureFiles { get; set; } = false; } \ No newline at end of file diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 726f2ef..ec722c5 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -27,7 +27,6 @@ - @@ -39,7 +38,6 @@ - all @@ -77,7 +75,23 @@ - + + + + + + + + lib\OtterTex.dll + true + + + + + + PreserveNewest + DirectXTexC.dll + diff --git a/LightlessSync/PlayerData/Data/FileReplacementDataComparer.cs b/LightlessSync/PlayerData/Data/FileReplacementDataComparer.cs index 7b52b49..5ba716b 100644 --- a/LightlessSync/PlayerData/Data/FileReplacementDataComparer.cs +++ b/LightlessSync/PlayerData/Data/FileReplacementDataComparer.cs @@ -1,4 +1,7 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; +using System; +using System.Collections.Generic; +using System.Linq; namespace LightlessSync.PlayerData.Data; @@ -13,37 +16,42 @@ public class FileReplacementDataComparer : IEqualityComparer list1, HashSet list2) + private static bool ComparePathSets(IEnumerable first, IEnumerable second) { - if (list1.Count != list2.Count) - return false; - - for (int i = 0; i < list1.Count; i++) - { - if (!string.Equals(list1.ElementAt(i), list2.ElementAt(i), StringComparison.OrdinalIgnoreCase)) - return false; - } - - return true; + var left = new HashSet(first ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + var right = new HashSet(second ?? Enumerable.Empty(), StringComparer.OrdinalIgnoreCase); + return left.SetEquals(right); } - private static int GetOrderIndependentHashCode(IEnumerable source) where T : notnull + private static int GetSetHashCode(IEnumerable paths) { int hash = 0; - foreach (T element in source) + foreach (var element in paths ?? Enumerable.Empty()) { - hash = unchecked(hash + - EqualityComparer.Default.GetHashCode(element)); + hash = unchecked(hash + StringComparer.OrdinalIgnoreCase.GetHashCode(element)); } + return hash; } } \ No newline at end of file diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index 231ded3..81e3ecb 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -2,6 +2,7 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; @@ -9,13 +10,15 @@ namespace LightlessSync.PlayerData.Factories; public class FileDownloadManagerFactory { - private readonly FileCacheManager _fileCacheManager; - private readonly FileCompactor _fileCompactor; - private readonly FileTransferOrchestrator _fileTransferOrchestrator; - private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ILoggerFactory _loggerFactory; private readonly LightlessMediator _lightlessMediator; + private readonly FileTransferOrchestrator _fileTransferOrchestrator; + private readonly FileCacheManager _fileCacheManager; + private readonly FileCompactor _fileCompactor; + private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly LightlessConfigService _configService; + private readonly TextureDownscaleService _textureDownscaleService; + private readonly TextureMetadataHelper _textureMetadataHelper; public FileDownloadManagerFactory( ILoggerFactory loggerFactory, @@ -24,7 +27,9 @@ public class FileDownloadManagerFactory FileCacheManager fileCacheManager, FileCompactor fileCompactor, PairProcessingLimiter pairProcessingLimiter, - LightlessConfigService configService) + LightlessConfigService configService, + TextureDownscaleService textureDownscaleService, + TextureMetadataHelper textureMetadataHelper) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -33,6 +38,8 @@ public class FileDownloadManagerFactory _fileCompactor = fileCompactor; _pairProcessingLimiter = pairProcessingLimiter; _configService = configService; + _textureDownscaleService = textureDownscaleService; + _textureMetadataHelper = textureMetadataHelper; } public FileDownloadManager Create() @@ -44,6 +51,8 @@ public class FileDownloadManagerFactory _fileCacheManager, _fileCompactor, _pairProcessingLimiter, - _configService); + _configService, + _textureDownscaleService, + _textureMetadataHelper); } } diff --git a/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs b/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs index 83a7ce6..4741b55 100644 --- a/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs +++ b/LightlessSync/PlayerData/Factories/GameObjectHandlerFactory.cs @@ -2,29 +2,40 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Factories; public class GameObjectHandlerFactory { - private readonly DalamudUtilService _dalamudUtilService; + private readonly IServiceProvider _serviceProvider; private readonly ILoggerFactory _loggerFactory; private readonly LightlessMediator _lightlessMediator; private readonly PerformanceCollectorService _performanceCollectorService; - public GameObjectHandlerFactory(ILoggerFactory loggerFactory, PerformanceCollectorService performanceCollectorService, LightlessMediator lightlessMediator, - DalamudUtilService dalamudUtilService) + public GameObjectHandlerFactory( + ILoggerFactory loggerFactory, + PerformanceCollectorService performanceCollectorService, + LightlessMediator lightlessMediator, + IServiceProvider serviceProvider) { _loggerFactory = loggerFactory; _performanceCollectorService = performanceCollectorService; _lightlessMediator = lightlessMediator; - _dalamudUtilService = dalamudUtilService; + _serviceProvider = serviceProvider; } public async Task Create(ObjectKind objectKind, Func getAddressFunc, bool isWatched = false) { - return await _dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler(_loggerFactory.CreateLogger(), - _performanceCollectorService, _lightlessMediator, _dalamudUtilService, objectKind, getAddressFunc, isWatched)).ConfigureAwait(false); + var dalamudUtilService = _serviceProvider.GetRequiredService(); + return await dalamudUtilService.RunOnFrameworkThread(() => new GameObjectHandler( + _loggerFactory.CreateLogger(), + _performanceCollectorService, + _lightlessMediator, + dalamudUtilService, + objectKind, + getAddressFunc, + isWatched)).ConfigureAwait(false); } } \ No newline at end of file diff --git a/LightlessSync/PlayerData/Factories/PairFactory.cs b/LightlessSync/PlayerData/Factories/PairFactory.cs index a9ee0ab..fd63f51 100644 --- a/LightlessSync/PlayerData/Factories/PairFactory.cs +++ b/LightlessSync/PlayerData/Factories/PairFactory.cs @@ -1,35 +1,86 @@ -using LightlessSync.API.Dto.User; +using System; +using System.Collections.Generic; +using System.Linq; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Dto.User; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; +using LightlessSync.WebAPI; namespace LightlessSync.PlayerData.Factories; public class PairFactory { - private readonly PairHandlerFactory _cachedPlayerFactory; + private readonly PairLedger _pairLedger; private readonly ILoggerFactory _loggerFactory; private readonly LightlessMediator _lightlessMediator; - private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly Lazy _serverConfigurationManager; + private readonly Lazy _apiController; - public PairFactory(ILoggerFactory loggerFactory, PairHandlerFactory cachedPlayerFactory, - LightlessMediator lightlessMediator, ServerConfigurationManager serverConfigurationManager) + public PairFactory( + ILoggerFactory loggerFactory, + PairLedger pairLedger, + LightlessMediator lightlessMediator, + Lazy serverConfigurationManager, + Lazy apiController) { _loggerFactory = loggerFactory; - _cachedPlayerFactory = cachedPlayerFactory; + _pairLedger = pairLedger; _lightlessMediator = lightlessMediator; _serverConfigurationManager = serverConfigurationManager; + _apiController = apiController; } public Pair Create(UserFullPairDto userPairDto) { - return new Pair(_loggerFactory.CreateLogger(), userPairDto, _cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager); + return CreateInternal(userPairDto); } public Pair Create(UserPairDto userPairDto) { - return new Pair(_loggerFactory.CreateLogger(), new(userPairDto.User, userPairDto.IndividualPairStatus, [], userPairDto.OwnPermissions, userPairDto.OtherPermissions), - _cachedPlayerFactory, _lightlessMediator, _serverConfigurationManager); + var full = new UserFullPairDto( + userPairDto.User, + userPairDto.IndividualPairStatus, + new List(), + userPairDto.OwnPermissions, + userPairDto.OtherPermissions); + + return CreateInternal(full); } -} \ No newline at end of file + + public Pair? Create(PairDisplayEntry entry) + { + var dto = new UserFullPairDto( + entry.User, + entry.PairStatus ?? IndividualPairStatus.None, + entry.Groups.Select(g => g.Group.GID).Distinct(StringComparer.Ordinal).ToList(), + entry.SelfPermissions, + entry.OtherPermissions); + + return CreateInternal(dto); + } + + public Pair? Create(PairUniqueIdentifier ident) + { + if (!_pairLedger.TryGetEntry(ident, out var entry) || entry is null) + { + return null; + } + + return Create(entry); + } + + private Pair CreateInternal(UserFullPairDto dto) + { + return new Pair( + _loggerFactory.CreateLogger(), + dto, + _pairLedger, + _lightlessMediator, + _serverConfigurationManager.Value, + _apiController); + } +} diff --git a/LightlessSync/PlayerData/Factories/PairHandlerFactory.cs b/LightlessSync/PlayerData/Factories/PairHandlerFactory.cs deleted file mode 100644 index 9cb74da..0000000 --- a/LightlessSync/PlayerData/Factories/PairHandlerFactory.cs +++ /dev/null @@ -1,55 +0,0 @@ -using LightlessSync.FileCache; -using LightlessSync.Interop.Ipc; -using LightlessSync.PlayerData.Handlers; -using LightlessSync.PlayerData.Pairs; -using LightlessSync.Services; -using LightlessSync.Services.Mediator; -using LightlessSync.Services.ServerConfiguration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace LightlessSync.PlayerData.Factories; - -public class PairHandlerFactory -{ - private readonly DalamudUtilService _dalamudUtilService; - private readonly FileCacheManager _fileCacheManager; - private readonly FileDownloadManagerFactory _fileDownloadManagerFactory; - private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; - private readonly IHostApplicationLifetime _hostApplicationLifetime; - private readonly IpcManager _ipcManager; - private readonly ILoggerFactory _loggerFactory; - private readonly LightlessMediator _lightlessMediator; - private readonly PlayerPerformanceService _playerPerformanceService; - private readonly PairProcessingLimiter _pairProcessingLimiter; - private readonly ServerConfigurationManager _serverConfigManager; - private readonly PluginWarningNotificationService _pluginWarningNotificationManager; - - public PairHandlerFactory(ILoggerFactory loggerFactory, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager, - FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService, - PluginWarningNotificationService pluginWarningNotificationManager, IHostApplicationLifetime hostApplicationLifetime, - FileCacheManager fileCacheManager, LightlessMediator lightlessMediator, PlayerPerformanceService playerPerformanceService, - PairProcessingLimiter pairProcessingLimiter, - ServerConfigurationManager serverConfigManager) - { - _loggerFactory = loggerFactory; - _gameObjectHandlerFactory = gameObjectHandlerFactory; - _ipcManager = ipcManager; - _fileDownloadManagerFactory = fileDownloadManagerFactory; - _dalamudUtilService = dalamudUtilService; - _pluginWarningNotificationManager = pluginWarningNotificationManager; - _hostApplicationLifetime = hostApplicationLifetime; - _fileCacheManager = fileCacheManager; - _lightlessMediator = lightlessMediator; - _playerPerformanceService = playerPerformanceService; - _pairProcessingLimiter = pairProcessingLimiter; - _serverConfigManager = serverConfigManager; - } - - public PairHandler Create(Pair pair) - { - return new PairHandler(_loggerFactory.CreateLogger(), pair, _gameObjectHandlerFactory, - _ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _dalamudUtilService, _hostApplicationLifetime, - _fileCacheManager, _lightlessMediator, _playerPerformanceService, _pairProcessingLimiter, _serverConfigManager); - } -} \ No newline at end of file diff --git a/LightlessSync/PlayerData/Handlers/PairHandler.cs b/LightlessSync/PlayerData/Handlers/PairHandler.cs deleted file mode 100644 index fb03bfb..0000000 --- a/LightlessSync/PlayerData/Handlers/PairHandler.cs +++ /dev/null @@ -1,775 +0,0 @@ -using LightlessSync.API.Data; -using LightlessSync.FileCache; -using LightlessSync.Interop.Ipc; -using LightlessSync.PlayerData.Factories; -using LightlessSync.PlayerData.Pairs; -using LightlessSync.Services; -using LightlessSync.Services.Events; -using LightlessSync.Services.Mediator; -using LightlessSync.Services.ServerConfiguration; -using LightlessSync.Utils; -using LightlessSync.WebAPI.Files; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Diagnostics; -using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; - -namespace LightlessSync.PlayerData.Handlers; - -public sealed class PairHandler : DisposableMediatorSubscriberBase -{ - private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); - - private readonly DalamudUtilService _dalamudUtil; - private readonly FileDownloadManager _downloadManager; - private readonly FileCacheManager _fileDbManager; - private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; - private readonly IpcManager _ipcManager; - private readonly IHostApplicationLifetime _lifetime; - private readonly PlayerPerformanceService _playerPerformanceService; - private readonly PairProcessingLimiter _pairProcessingLimiter; - private readonly ServerConfigurationManager _serverConfigManager; - private readonly PluginWarningNotificationService _pluginWarningNotificationManager; - private CancellationTokenSource? _applicationCancellationTokenSource = new(); - private Guid _applicationId; - private Task? _applicationTask; - private CharacterData? _cachedData = null; - private GameObjectHandler? _charaHandler; - private readonly Dictionary _customizeIds = []; - private CombatData? _dataReceivedInDowntime; - private CancellationTokenSource? _downloadCancellationTokenSource = new(); - private bool _forceApplyMods = false; - private bool _isVisible; - private Guid _penumbraCollection; - private bool _redrawOnNextApplication = false; - - public PairHandler(ILogger logger, Pair pair, - GameObjectHandlerFactory gameObjectHandlerFactory, - IpcManager ipcManager, FileDownloadManager transferManager, - PluginWarningNotificationService pluginWarningNotificationManager, - DalamudUtilService dalamudUtil, IHostApplicationLifetime lifetime, - FileCacheManager fileDbManager, LightlessMediator mediator, - PlayerPerformanceService playerPerformanceService, - PairProcessingLimiter pairProcessingLimiter, - ServerConfigurationManager serverConfigManager) : base(logger, mediator) - { - Pair = pair; - _gameObjectHandlerFactory = gameObjectHandlerFactory; - _ipcManager = ipcManager; - _downloadManager = transferManager; - _pluginWarningNotificationManager = pluginWarningNotificationManager; - _dalamudUtil = dalamudUtil; - _lifetime = lifetime; - _fileDbManager = fileDbManager; - _playerPerformanceService = playerPerformanceService; - _pairProcessingLimiter = pairProcessingLimiter; - _serverConfigManager = serverConfigManager; - _penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult(); - - Mediator.Subscribe(this, (_) => FrameworkUpdate()); - Mediator.Subscribe(this, (_) => - { - _downloadCancellationTokenSource?.CancelDispose(); - _charaHandler?.Invalidate(); - IsVisible = false; - }); - Mediator.Subscribe(this, (_) => - { - _penumbraCollection = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(logger, Pair.UserData.UID).ConfigureAwait(false).GetAwaiter().GetResult(); - if (!IsVisible && _charaHandler != null) - { - PlayerName = string.Empty; - _charaHandler.Dispose(); - _charaHandler = null; - } - }); - Mediator.Subscribe(this, (msg) => - { - if (msg.GameObjectHandler == _charaHandler) - { - _redrawOnNextApplication = true; - } - }); - Mediator.Subscribe(this, (msg) => - { - EnableSync(); - }); - Mediator.Subscribe(this, _ => - { - DisableSync(); - }); - Mediator.Subscribe(this, (msg) => - { - EnableSync(); - }); - Mediator.Subscribe(this, _ => - { - DisableSync(); - }); - Mediator.Subscribe(this, _ => - { - DisableSync(); - }); - Mediator.Subscribe(this, (msg) => - { - EnableSync(); - - }); - - LastAppliedDataBytes = -1; - } - - public bool IsVisible - { - get => _isVisible; - private set - { - if (_isVisible != value) - { - _isVisible = value; - string text = "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible"); - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), - EventSeverity.Informational, text))); - Mediator.Publish(new RefreshUiMessage()); - Mediator.Publish(new VisibilityChange()); - } - } - } - - public long LastAppliedDataBytes { get; private set; } - public Pair Pair { get; private set; } - public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero; - public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero - ? uint.MaxValue - : ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId; - public string? PlayerName { get; private set; } - public string PlayerNameHash => Pair.Ident; - - public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) - { - if (_dalamudUtil.IsInCombat) - { - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, - "Cannot apply character data: you are in combat, deferring application"))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); - return; - } - - if (_dalamudUtil.IsPerforming) - { - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, - "Cannot apply character data: you are performing music, deferring application"))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); - return; - } - - if (_dalamudUtil.IsInInstance) - { - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, - "Cannot apply character data: you are in an instance, deferring application"))); - Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase); - _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); - return; - } - - if (_charaHandler == null || (PlayerCharacter == IntPtr.Zero)) - { - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, - "Cannot apply character data: Receiving Player is in an invalid state, deferring application"))); - Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}", - applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero); - var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, - this, forceApplyCustomization, forceApplyMods: false) - .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); - _forceApplyMods = hasDiffMods || _forceApplyMods || (PlayerCharacter == IntPtr.Zero && _cachedData == null); - _cachedData = characterData; - Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); - return; - } - - SetUploading(isUploading: false); - - Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, this, forceApplyCustomization, _forceApplyMods); - Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); - - if (string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) && !forceApplyCustomization) return; - - if (_dalamudUtil.IsInCutscene || _dalamudUtil.IsInGpose || !_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable) - { - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Warning, - "Cannot apply character data: you are in GPose, a Cutscene or Penumbra/Glamourer is not available"))); - Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while in cutscene/gpose or Penumbra/Glamourer unavailable, returning", applicationBase, this); - return; - } - - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, - "Applying Character Data"))); - - _forceApplyMods |= forceApplyCustomization; - - var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); - - if (_charaHandler != null && _forceApplyMods) - { - _forceApplyMods = false; - } - - if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player)) - { - player.Add(PlayerChanges.ForcedRedraw); - _redrawOnNextApplication = false; - } - - if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges)) - { - _pluginWarningNotificationManager.NotifyForMissingPlugins(Pair.UserData, PlayerName!, playerChanges); - } - - Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, this); - - DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); - } - - public override string ToString() - { - return Pair == null - ? base.ToString() ?? string.Empty - : Pair.UserData.AliasOrUID + ":" + PlayerName + ":" + (PlayerCharacter != nint.Zero ? "HasChar" : "NoChar"); - } - - internal void SetUploading(bool isUploading = true) - { - Logger.LogTrace("Setting {this} uploading {uploading}", this, isUploading); - if (_charaHandler != null) - { - Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading)); - } - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - SetUploading(isUploading: false); - var name = PlayerName; - Logger.LogDebug("Disposing {name} ({user})", name, Pair); - try - { - Guid applicationId = Guid.NewGuid(); - _applicationCancellationTokenSource?.CancelDispose(); - _applicationCancellationTokenSource = null; - _downloadCancellationTokenSource?.CancelDispose(); - _downloadCancellationTokenSource = null; - _downloadManager.Dispose(); - _charaHandler?.Dispose(); - _charaHandler = null; - - if (!string.IsNullOrEmpty(name)) - { - Mediator.Publish(new EventMessage(new Event(name, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, "Disposing User"))); - } - - if (_lifetime.ApplicationStopping.IsCancellationRequested) return; - - if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name)) - { - Logger.LogTrace("[{applicationId}] Restoring state for {name} ({OnlineUser})", applicationId, name, Pair.UserPair); - Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, Pair.UserPair); - _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, _penumbraCollection).GetAwaiter().GetResult(); - if (!IsVisible) - { - Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, Pair.UserPair); - _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); - } - else - { - using var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromSeconds(60)); - - Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); - - foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) - { - try - { - RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); - } - catch (InvalidOperationException ex) - { - Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); - break; - } - } - } - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error on disposal of {name}", name); - } - finally - { - PlayerName = null; - _cachedData = null; - Logger.LogDebug("Disposing {name} complete", name); - } - } - - private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) - { - if (PlayerCharacter == nint.Zero) return; - var ptr = PlayerCharacter; - - var handler = changes.Key switch - { - ObjectKind.Player => _charaHandler!, - ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false), - ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false), - ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false), - _ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key) - }; - - try - { - if (handler.Address == nint.Zero) - { - return; - } - - Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); - await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - foreach (var change in changes.Value.OrderBy(p => (int)p)) - { - Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler); - switch (change) - { - case PlayerChanges.Customize: - if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData)) - { - _customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false); - } - else if (_customizeIds.TryGetValue(changes.Key, out var customizeId)) - { - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - _customizeIds.Remove(changes.Key); - } - break; - - case PlayerChanges.Heels: - await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false); - break; - - case PlayerChanges.Honorific: - await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false); - break; - - case PlayerChanges.Glamourer: - if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData)) - { - await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false); - } - break; - - case PlayerChanges.Moodles: - await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false); - break; - - case PlayerChanges.PetNames: - await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false); - break; - - case PlayerChanges.ForcedRedraw: - await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); - break; - - default: - break; - } - token.ThrowIfCancellationRequested(); - } - } - finally - { - if (handler != _charaHandler) handler.Dispose(); - } - } - - private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) - { - if (!updatedData.Any()) - { - Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, this); - return; - } - - var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); - var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); - - _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); - var downloadToken = _downloadCancellationTokenSource.Token; - - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false); - } - - private Task? _pairDownloadTask; - - private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, - bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) - { - await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); - Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; - - if (updateModdedPaths) - { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) - { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) - { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); - await _pairDownloadTask.ConfigureAwait(false); - } - - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); - - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) - { - _downloadManager.ClearDownload(); - return; - } - - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false)); - - await _pairDownloadTask.ConfigureAwait(false); - - if (downloadToken.IsCancellationRequested) - { - Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - return; - } - - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); - } - - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) - return; - } - - downloadToken.ThrowIfCancellationRequested(); - - var appToken = _applicationCancellationTokenSource?.Token; - while ((!_applicationTask?.IsCompleted ?? false) - && !downloadToken.IsCancellationRequested - && (!appToken?.IsCancellationRequested ?? false)) - { - // block until current application is done - Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); - await Task.Delay(250).ConfigureAwait(false); - } - - if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) return; - - _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); - var token = _applicationCancellationTokenSource.Token; - - _applicationTask = ApplyCharacterDataAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); - } - - private async Task ApplyCharacterDataAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, - Dictionary<(string GamePath, string? Hash), string> moddedPaths, CancellationToken token) - { - try - { - _applicationId = Guid.NewGuid(); - Logger.LogDebug("[BASE-{applicationId}] Starting application task for {this}: {appId}", applicationBase, this, _applicationId); - - Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, _charaHandler); - await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, _charaHandler!, _applicationId, 30000, token).ConfigureAwait(false); - - token.ThrowIfCancellationRequested(); - - if (updateModdedPaths) - { - // ensure collection is set - var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler!.GetGameObject()!.ObjectIndex).ConfigureAwait(false); - await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, objIndex).ConfigureAwait(false); - - await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection, - moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); - LastAppliedDataBytes = -1; - foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) - { - if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0; - - LastAppliedDataBytes += path.Length; - } - } - - if (updateManip) - { - await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, _penumbraCollection, charaData.ManipulationData).ConfigureAwait(false); - } - - token.ThrowIfCancellationRequested(); - - foreach (var kind in updatedData) - { - await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); - token.ThrowIfCancellationRequested(); - } - - _cachedData = charaData; - - Logger.LogDebug("[{applicationId}] Application finished", _applicationId); - } - catch (Exception ex) - { - if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) - { - IsVisible = false; - _forceApplyMods = true; - _cachedData = charaData; - Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); - } - else - { - Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - } - } - } - - private void FrameworkUpdate() - { - if (string.IsNullOrEmpty(PlayerName)) - { - var pc = _dalamudUtil.FindPlayerByNameHash(Pair.Ident); - if (pc == default((string, nint))) return; - Logger.LogDebug("One-Time Initializing {this}", this); - Initialize(pc.Name); - Logger.LogDebug("One-Time Initialized {this}", this); - Mediator.Publish(new EventMessage(new Event(PlayerName, Pair.UserData, nameof(PairHandler), EventSeverity.Informational, - $"Initializing User For Character {pc.Name}"))); - } - - if (_charaHandler?.Address != nint.Zero && !IsVisible) - { - Guid appData = Guid.NewGuid(); - IsVisible = true; - if (_cachedData != null) - { - Logger.LogTrace("[BASE-{appBase}] {this} visibility changed, now: {visi}, cached data exists", appData, this, IsVisible); - - _ = Task.Run(() => - { - ApplyCharacterData(appData, _cachedData!, forceApplyCustomization: true); - }); - } - else - { - Logger.LogTrace("{this} visibility changed, now: {visi}, no cached data exists", this, IsVisible); - } - } - else if (_charaHandler?.Address == nint.Zero && IsVisible) - { - IsVisible = false; - _charaHandler.Invalidate(); - _downloadCancellationTokenSource?.CancelDispose(); - _downloadCancellationTokenSource = null; - Logger.LogTrace("{this} visibility changed, now: {visi}", this, IsVisible); - } - } - - private void Initialize(string name) - { - PlayerName = name; - _charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident), isWatched: false).GetAwaiter().GetResult(); - - _serverConfigManager.AutoPopulateNoteForUid(Pair.UserData.UID, name); - - Mediator.Subscribe(this, async (_) => - { - if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return; - Logger.LogTrace("Reapplying Honorific data for {this}", this); - await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false); - }); - - Mediator.Subscribe(this, async (_) => - { - if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return; - Logger.LogTrace("Reapplying Pet Names data for {this}", this); - await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false); - }); - - _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, _penumbraCollection, _charaHandler.GetGameObject()!.ObjectIndex).GetAwaiter().GetResult(); - } - - private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken) - { - nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Pair.Ident); - if (address == nint.Zero) return; - - Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, Pair.UserData.AliasOrUID, name, objectKind); - - if (_customizeIds.TryGetValue(objectKind, out var customizeId)) - { - _customizeIds.Remove(objectKind); - } - - if (objectKind == ObjectKind.Player) - { - using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false); - tempHandler.CompareNameAndThrow(name); - Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - tempHandler.CompareNameAndThrow(name); - Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false); - tempHandler.CompareNameAndThrow(name); - Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - tempHandler.CompareNameAndThrow(name); - Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false); - Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false); - Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, Pair.UserData.AliasOrUID, name); - await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false); - } - else if (objectKind == ObjectKind.MinionOrMount) - { - var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false); - if (minionOrMount != nint.Zero) - { - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false); - await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - } - } - else if (objectKind == ObjectKind.Pet) - { - var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false); - if (pet != nint.Zero) - { - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false); - await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - } - } - else if (objectKind == ObjectKind.Companion) - { - var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false); - if (companion != nint.Zero) - { - await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false); - await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); - } - } - } - - private List TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token) - { - Stopwatch st = Stopwatch.StartNew(); - ConcurrentBag missingFiles = []; - moddedDictionary = []; - ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new(); - bool hasMigrationChanges = false; - - try - { - var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList(); - Parallel.ForEach(replacementList, new ParallelOptions() - { - CancellationToken = token, - MaxDegreeOfParallelism = 4 - }, - (item) => - { - token.ThrowIfCancellationRequested(); - var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash); - if (fileCache != null) - { - if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) - { - hasMigrationChanges = true; - fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); - } - - foreach (var gamePath in item.GamePaths) - { - outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath; - } - } - else - { - Logger.LogTrace("Missing file: {hash}", item.Hash); - missingFiles.Add(item); - } - }); - - moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value); - - foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList()) - { - foreach (var gamePath in item.GamePaths) - { - Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath); - moddedDictionary[(gamePath, null)] = item.FileSwapPath; - } - } - } - catch (OperationCanceledException) - { - Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase); - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase); - } - if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv(); - st.Stop(); - Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count); - return [.. missingFiles]; - } - - private void DisableSync() - { - _dataReceivedInDowntime = null; - _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); - _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); - } - - private void EnableSync() - { - if (IsVisible && _dataReceivedInDowntime != null) - { - ApplyCharacterData(_dataReceivedInDowntime.ApplicationId, - _dataReceivedInDowntime.CharacterData, _dataReceivedInDowntime.Forced); - _dataReceivedInDowntime = null; - } - } -} diff --git a/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs new file mode 100644 index 0000000..a11893b --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs @@ -0,0 +1,16 @@ +using LightlessSync.API.Data; + +namespace LightlessSync.PlayerData.Pairs; + +public interface IPairPerformanceSubject +{ + string Ident { get; } + string PlayerName { get; } + UserData UserData { get; } + bool IsPaused { get; } + bool IsDirectlyPaired { get; } + bool HasStickyPermissions { get; } + long LastAppliedApproximateVRAMBytes { get; set; } + long LastAppliedApproximateEffectiveVRAMBytes { get; set; } + long LastAppliedDataTris { get; set; } +} diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index d4e2950..7709b06 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -1,103 +1,133 @@ -using Dalamud.Game.Gui.ContextMenu; +using System; +using System.Linq; +using Dalamud.Game.Gui.ContextMenu; using Dalamud.Game.Text.SeStringHandling; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.User; -using LightlessSync.PlayerData.Factories; -using LightlessSync.PlayerData.Handlers; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; -using LightlessSync.Utils; using Microsoft.Extensions.Logging; +using LightlessSync.WebAPI; namespace LightlessSync.PlayerData.Pairs; public class Pair { - private readonly PairHandlerFactory _cachedPlayerFactory; - private readonly SemaphoreSlim _creationSemaphore = new(1); + private readonly PairLedger _pairLedger; private readonly ILogger _logger; private readonly LightlessMediator _mediator; private readonly ServerConfigurationManager _serverConfigurationManager; - private CancellationTokenSource _applicationCts = new(); - private OnlineUserIdentDto? _onlineUserIdentDto = null; + private readonly Lazy _apiController; - public Pair(ILogger logger, UserFullPairDto userPair, PairHandlerFactory cachedPlayerFactory, - LightlessMediator mediator, ServerConfigurationManager serverConfigurationManager) + public Pair( + ILogger logger, + UserFullPairDto userPair, + PairLedger pairLedger, + LightlessMediator mediator, + ServerConfigurationManager serverConfigurationManager, + Lazy apiController) { _logger = logger; UserPair = userPair; - _cachedPlayerFactory = cachedPlayerFactory; + _pairLedger = pairLedger; _mediator = mediator; _serverConfigurationManager = serverConfigurationManager; + _apiController = apiController; } - public bool HasCachedPlayer => CachedPlayer != null && !string.IsNullOrEmpty(CachedPlayer.PlayerName) && _onlineUserIdentDto != null; + private PairUniqueIdentifier PairIdent => UniqueIdent; + + private IPairHandlerAdapter? TryGetHandler() + { + return _pairLedger.GetHandler(PairIdent); + } + + private PairConnection? TryGetConnection() + { + return _pairLedger.TryGetEntry(PairIdent, out var entry) && entry is not null + ? entry.Connection + : null; + } + + public bool HasCachedPlayer => TryGetHandler() is not null; public IndividualPairStatus IndividualPairStatus => UserPair.IndividualPairStatus; public bool IsDirectlyPaired => IndividualPairStatus != IndividualPairStatus.None; public bool IsOneSidedPair => IndividualPairStatus == IndividualPairStatus.OneSided; - public bool IsOnline => CachedPlayer != null; + + public bool IsOnline => TryGetConnection()?.IsOnline ?? false; public bool IsPaired => IndividualPairStatus == IndividualPairStatus.Bidirectional || UserPair.Groups.Any(); public bool IsPaused => UserPair.OwnPermissions.IsPaused(); - public bool IsVisible => CachedPlayer?.IsVisible ?? false; - public CharacterData? LastReceivedCharacterData { get; set; } - public string? PlayerName => CachedPlayer?.PlayerName ?? string.Empty; - public long LastAppliedDataBytes => CachedPlayer?.LastAppliedDataBytes ?? -1; - public long LastAppliedDataTris { get; set; } = -1; - public long LastAppliedApproximateVRAMBytes { get; set; } = -1; - public string Ident => _onlineUserIdentDto?.Ident ?? string.Empty; - public uint PlayerCharacterId => CachedPlayer?.PlayerCharacterId ?? uint.MaxValue; + public bool IsVisible => _pairLedger.IsPairVisible(PairIdent); + public CharacterData? LastReceivedCharacterData => TryGetHandler()?.LastReceivedCharacterData; + public string? PlayerName => TryGetHandler()?.PlayerName ?? UserPair.User.AliasOrUID; + public long LastAppliedDataBytes => TryGetHandler()?.LastAppliedDataBytes ?? -1; + public long LastAppliedDataTris => TryGetHandler()?.LastAppliedDataTris ?? -1; + public long LastAppliedApproximateVRAMBytes => TryGetHandler()?.LastAppliedApproximateVRAMBytes ?? -1; + public long LastAppliedApproximateEffectiveVRAMBytes => TryGetHandler()?.LastAppliedApproximateEffectiveVRAMBytes ?? -1; + public string Ident => TryGetHandler()?.Ident ?? TryGetConnection()?.Ident ?? string.Empty; + public uint PlayerCharacterId => TryGetHandler()?.PlayerCharacterId ?? uint.MaxValue; + public PairUniqueIdentifier UniqueIdent => new(UserData.UID); public UserData UserData => UserPair.User; public UserFullPairDto UserPair { get; set; } - private PairHandler? CachedPlayer { get; set; } public void AddContextMenu(IMenuOpenedArgs args) { - if (CachedPlayer == null || (args.Target is not MenuTargetDefault target) || target.TargetObjectId != CachedPlayer.PlayerCharacterId || IsPaused) return; + var handler = TryGetHandler(); + if (handler is null) + { + return; + } - SeStringBuilder seStringBuilder = new(); - SeStringBuilder seStringBuilder2 = new(); - SeStringBuilder seStringBuilder3 = new(); - SeStringBuilder seStringBuilder4 = new(); - var openProfileSeString = seStringBuilder.AddText("Open Profile").Build(); - var reapplyDataSeString = seStringBuilder2.AddText("Reapply last data").Build(); - var cyclePauseState = seStringBuilder3.AddText("Cycle pause state").Build(); - var changePermissions = seStringBuilder4.AddText("Change Permissions").Build(); - args.AddMenuItem(new MenuItem() + if (args.Target is not MenuTargetDefault target || target.TargetObjectId != handler.PlayerCharacterId || IsPaused) + { + return; + } + + var openProfileSeString = new SeStringBuilder().AddText("Open Profile").Build(); + var reapplyDataSeString = new SeStringBuilder().AddText("Reapply last data").Build(); + var cyclePauseState = new SeStringBuilder().AddText("Cycle pause state").Build(); + var changePermissions = new SeStringBuilder().AddText("Change Permissions").Build(); + + args.AddMenuItem(new MenuItem { Name = openProfileSeString, - OnClicked = (a) => _mediator.Publish(new ProfileOpenStandaloneMessage(this)), + OnClicked = _ => _mediator.Publish(new ProfileOpenStandaloneMessage(this)), UseDefaultPrefix = false, PrefixChar = 'L', PrefixColor = 708 }); - args.AddMenuItem(new MenuItem() + args.AddMenuItem(new MenuItem { Name = reapplyDataSeString, - OnClicked = (a) => ApplyLastReceivedData(forced: true), + OnClicked = _ => ApplyLastReceivedData(forced: true), UseDefaultPrefix = false, PrefixChar = 'L', PrefixColor = 708 }); - args.AddMenuItem(new MenuItem() + args.AddMenuItem(new MenuItem { Name = changePermissions, - OnClicked = (a) => _mediator.Publish(new OpenPermissionWindow(this)), + OnClicked = _ => _mediator.Publish(new OpenPermissionWindow(this)), UseDefaultPrefix = false, PrefixChar = 'L', PrefixColor = 708 }); - args.AddMenuItem(new MenuItem() + args.AddMenuItem(new MenuItem { Name = cyclePauseState, - OnClicked = (a) => _mediator.Publish(new CyclePauseMessage(UserData)), + OnClicked = _ => + { + TriggerCyclePause(); + }, UseDefaultPrefix = false, PrefixChar = 'L', PrefixColor = 708 @@ -106,68 +136,38 @@ public class Pair public void ApplyData(OnlineUserCharaDataDto data) { - _applicationCts = _applicationCts.CancelRecreate(); - LastReceivedCharacterData = data.CharaData; + _logger.LogTrace("Character data received for {Uid}; handler will process via registry.", UserData.UID); + } - if (CachedPlayer == null) - { - _logger.LogDebug("Received Data for {uid} but CachedPlayer does not exist, waiting", data.User.UID); - _ = Task.Run(async () => - { - using var timeoutCts = new CancellationTokenSource(); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(120)); - var appToken = _applicationCts.Token; - using var combined = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, appToken); - while (CachedPlayer == null && !combined.Token.IsCancellationRequested) - { - await Task.Delay(250, combined.Token).ConfigureAwait(false); - } - - if (!combined.IsCancellationRequested) - { - _logger.LogDebug("Applying delayed data for {uid}", data.User.UID); - ApplyLastReceivedData(); - } - }); - return; - } - - ApplyLastReceivedData(); + private void TriggerCyclePause() + { + _ = _apiController.Value.CyclePauseAsync(this); } public void ApplyLastReceivedData(bool forced = false) { - if (CachedPlayer == null) return; - if (LastReceivedCharacterData == null) return; + var handler = TryGetHandler(); + if (handler is null) + { + _logger.LogTrace("ApplyLastReceivedData skipped for {Uid}: handler missing.", UserData.UID); + return; + } - CachedPlayer.ApplyCharacterData(Guid.NewGuid(), RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone())!, forced); + handler.ApplyLastReceivedData(forced); } public void CreateCachedPlayer(OnlineUserIdentDto? dto = null) { - try + var handler = TryGetHandler(); + if (handler is null) { - _creationSemaphore.Wait(); - - if (CachedPlayer != null) return; - - if (dto == null && _onlineUserIdentDto == null) - { - CachedPlayer?.Dispose(); - CachedPlayer = null; - return; - } - if (dto != null) - { - _onlineUserIdentDto = dto; - } - - CachedPlayer?.Dispose(); - CachedPlayer = _cachedPlayerFactory.Create(this); + _logger.LogTrace("CreateCachedPlayer skipped for {Uid}: handler unavailable.", UserData.UID); + return; } - finally + + if (!handler.Initialized) { - _creationSemaphore.Release(); + handler.Initialize(); } } @@ -178,7 +178,7 @@ public class Pair public string GetPlayerNameHash() { - return CachedPlayer?.PlayerNameHash ?? string.Empty; + return TryGetHandler()?.PlayerNameHash ?? string.Empty; } public bool HasAnyConnection() @@ -188,21 +188,7 @@ public class Pair public void MarkOffline(bool wait = true) { - try - { - if (wait) - _creationSemaphore.Wait(); - LastReceivedCharacterData = null; - var player = CachedPlayer; - CachedPlayer = null; - player?.Dispose(); - _onlineUserIdentDto = null; - } - finally - { - if (wait) - _creationSemaphore.Release(); - } + _logger.LogTrace("MarkOffline invoked for {Uid} (wait: {Wait}). New registry handles handler disposal.", UserData.UID, wait); } public void SetNote(string note) @@ -212,47 +198,12 @@ public class Pair internal void SetIsUploading() { - CachedPlayer?.SetUploading(); - } - - private CharacterData? RemoveNotSyncedFiles(CharacterData? data) - { - _logger.LogTrace("Removing not synced files"); - if (data == null) + var handler = TryGetHandler(); + if (handler is null) { - _logger.LogTrace("Nothing to remove"); - return data; + return; } - bool disableIndividualAnimations = (UserPair.OtherPermissions.IsDisableAnimations() || UserPair.OwnPermissions.IsDisableAnimations()); - bool disableIndividualVFX = (UserPair.OtherPermissions.IsDisableVFX() || UserPair.OwnPermissions.IsDisableVFX()); - bool disableIndividualSounds = (UserPair.OtherPermissions.IsDisableSounds() || UserPair.OwnPermissions.IsDisableSounds()); - - _logger.LogTrace("Disable: Sounds: {disableIndividualSounds}, Anims: {disableIndividualAnims}; " + - "VFX: {disableGroupSounds}", - disableIndividualSounds, disableIndividualAnimations, disableIndividualVFX); - - if (disableIndividualAnimations || disableIndividualSounds || disableIndividualVFX) - { - _logger.LogTrace("Data cleaned up: Animations disabled: {disableAnimations}, Sounds disabled: {disableSounds}, VFX disabled: {disableVFX}", - disableIndividualAnimations, disableIndividualSounds, disableIndividualVFX); - foreach (var objectKind in data.FileReplacements.Select(k => k.Key)) - { - if (disableIndividualSounds) - data.FileReplacements[objectKind] = data.FileReplacements[objectKind] - .Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase))) - .ToList(); - if (disableIndividualAnimations) - data.FileReplacements[objectKind] = data.FileReplacements[objectKind] - .Where(f => !f.GamePaths.Any(p => p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) || p.EndsWith("pap", StringComparison.OrdinalIgnoreCase))) - .ToList(); - if (disableIndividualVFX) - data.FileReplacements[objectKind] = data.FileReplacements[objectKind] - .Where(f => !f.GamePaths.Any(p => p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) || p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase))) - .ToList(); - } - } - - return data; + handler.SetUploading(true); } -} \ No newline at end of file +} diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs new file mode 100644 index 0000000..ddc4adb --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs @@ -0,0 +1,553 @@ +using System; +using System.Collections.Concurrent; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.Services.Mediator; +using LightlessSync.Services.Events; +using LightlessSync.Services.ServerConfiguration; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +public sealed class PairCoordinator : MediatorSubscriberBase +{ + private readonly ILogger _logger; + private readonly LightlessConfigService _configService; + private readonly LightlessMediator _mediator; + private readonly PairHandlerRegistry _handlerRegistry; + private readonly PairManager _pairManager; + private readonly PairLedger _pairLedger; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly ConcurrentDictionary _pendingCharacterData = new(StringComparer.Ordinal); + + public PairCoordinator( + ILogger logger, + LightlessConfigService configService, + LightlessMediator mediator, + PairHandlerRegistry handlerRegistry, + PairManager pairManager, + PairLedger pairLedger, + ServerConfigurationManager serverConfigurationManager) + : base(logger, mediator) + { + _logger = logger; + _configService = configService; + _mediator = mediator; + _handlerRegistry = handlerRegistry; + _pairManager = pairManager; + _pairLedger = pairLedger; + _serverConfigurationManager = serverConfigurationManager; + + mediator.Subscribe(this, msg => HandleActiveServerChange(msg.ServerUrl)); + mediator.Subscribe(this, _ => HandleDisconnected()); + } + + internal PairLedger Ledger => _pairLedger; + + private void PublishPairDataChanged(bool groupChanged = false) + { + _mediator.Publish(new RefreshUiMessage()); + _mediator.Publish(new PairDataChangedMessage()); + if (groupChanged) + { + _mediator.Publish(new GroupCollectionChangedMessage()); + } + } + + private void NotifyUserOnline(PairConnection? connection, bool sendNotification) + { + if (connection is null) + { + return; + } + + var config = _configService.Current; + if (config.ShowOnlineNotifications && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Pair {Uid} marked online", connection.User.UID); + } + + if (!sendNotification || !config.ShowOnlineNotifications) + { + return; + } + + if (config.ShowOnlineNotificationsOnlyForIndividualPairs && + (!connection.IsDirectlyPaired || connection.IsOneSided)) + { + return; + } + + var note = _serverConfigurationManager.GetNoteForUid(connection.User.UID); + if (config.ShowOnlineNotificationsOnlyForNamedPairs && + string.IsNullOrEmpty(note)) + { + return; + } + + var message = !string.IsNullOrEmpty(note) + ? $"{note} ({connection.User.AliasOrUID}) is now online" + : $"{connection.User.AliasOrUID} is now online"; + + _mediator.Publish(new NotificationMessage("User online", message, NotificationType.Info, TimeSpan.FromSeconds(5))); + } + + private void ReapplyLastKnownData(string userId, string ident, bool forced = false) + { + var result = _handlerRegistry.ApplyLastReceivedData(new PairUniqueIdentifier(userId), ident, forced); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to reapply cached data for {Uid}: {Error}", userId, result.Error); + } + } + + public void HandleGroupChangePermissions(GroupPermissionDto dto) + { + var result = _pairManager.UpdateGroupPermissions(dto); + if (!result.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update permissions for group {GroupId}: {Error}", dto.Group.GID, result.Error); + } + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupFullInfo(GroupFullInfoDto dto) + { + var result = _pairManager.AddGroup(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add group {GroupId}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairJoined(GroupPairFullInfoDto dto) + { + var result = _pairManager.AddOrUpdateGroupPair(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add group pair {Uid}/{Group}: {Error}", dto.User.UID, dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + private void HandleActiveServerChange(string serverUrl) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Active server changed to {Server}", serverUrl); + } + + ResetPairState(); + } + + private void HandleDisconnected() + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Lightless disconnected, clearing pair state"); + } + + ResetPairState(); + } + + private void ResetPairState() + { + _handlerRegistry.ResetAllHandlers(); + _pairManager.ClearAll(); + _pendingCharacterData.Clear(); + _mediator.Publish(new ClearProfileUserDataMessage()); + _mediator.Publish(new ClearProfileGroupDataMessage()); + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairLeft(GroupPairDto dto) + { + var deregistration = _pairManager.RemoveGroupPair(dto); + if (deregistration.Success && deregistration.Value is { } registration && registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + else if (!deregistration.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RemoveGroupPair failed for {Uid}: {Error}", dto.User.UID, deregistration.Error); + } + + if (deregistration.Success) + { + PublishPairDataChanged(groupChanged: true); + } + } + + public void HandleGroupRemoved(GroupDto dto) + { + var removalResult = _pairManager.RemoveGroup(dto.Group.GID); + if (removalResult.Success) + { + foreach (var registration in removalResult.Value) + { + if (registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + } + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to remove group {Group}: {Error}", dto.Group.GID, removalResult.Error); + } + + if (removalResult.Success) + { + PublishPairDataChanged(groupChanged: true); + } + } + + public void HandleGroupInfoUpdate(GroupInfoDto dto) + { + var result = _pairManager.UpdateGroupInfo(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group info for {Group}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairPermissions(GroupPairUserPermissionDto dto) + { + var result = _pairManager.UpdateGroupPairPermissions(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group pair permissions for {Group}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairStatus(GroupPairUserInfoDto dto, bool isSelf) + { + PairOperationResult result; + if (isSelf) + { + result = _pairManager.UpdateGroupStatus(dto); + } + else + { + result = _pairManager.UpdateGroupPairStatus(dto); + } + + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group status for {Group}:{Uid}: {Error}", dto.GID, dto.UID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleUserAddPair(UserPairDto dto, bool addToLastAddedUser = true) + { + var result = _pairManager.AddOrUpdateIndividual(dto, addToLastAddedUser); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add/update pair {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserAddPair(UserFullPairDto dto) + { + var result = _pairManager.AddOrUpdateIndividual(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add/update full pair {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserRemovePair(UserDto dto) + { + var removal = _pairManager.RemoveIndividual(dto); + if (removal.Success && removal.Value is { } registration && registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + else if (!removal.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RemoveIndividual failed for {Uid}: {Error}", dto.User.UID, removal.Error); + } + + if (removal.Success) + { + _pendingCharacterData.TryRemove(dto.User.UID, out _); + PublishPairDataChanged(); + } + } + + public void HandleUserStatus(UserIndividualPairStatusDto dto) + { + var result = _pairManager.SetIndividualStatus(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update individual pair status for {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserOnline(OnlineUserIdentDto dto, bool sendNotification) + { + var wasOnline = false; + PairConnection? previousConnection = null; + if (_pairManager.TryGetPair(dto.User.UID, out var existingConnection)) + { + previousConnection = existingConnection; + wasOnline = existingConnection.IsOnline; + } + + var registrationResult = _pairManager.MarkOnline(dto); + if (!registrationResult.Success) + { + _logger.LogDebug("MarkOnline failed for {Uid}: {Error}", dto.User.UID, registrationResult.Error); + return; + } + + var registration = registrationResult.Value; + if (registration.CharacterIdent is null) + { + _logger.LogDebug("Online registration for {Uid} missing ident.", dto.User.UID); + } + else + { + var handlerResult = _handlerRegistry.RegisterOnlinePair(registration); + if (!handlerResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RegisterOnlinePair failed for {Uid}: {Error}", dto.User.UID, handlerResult.Error); + } + } + + var connectionResult = _pairManager.GetPair(dto.User.UID); + var connection = connectionResult.Success ? connectionResult.Value : previousConnection; + if (connection is not null) + { + _mediator.Publish(new ClearProfileUserDataMessage(connection.User)); + } + else + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + } + + if (!wasOnline) + { + NotifyUserOnline(connection, sendNotification); + } + + if (registration.CharacterIdent is not null && + _pendingCharacterData.TryRemove(dto.User.UID, out var pendingData)) + { + var pendingRegistration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), registration.CharacterIdent); + var pendingApply = _handlerRegistry.ApplyCharacterData(pendingRegistration, pendingData); + if (!pendingApply.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Applying pending character data for {Uid} failed: {Error}", dto.User.UID, pendingApply.Error); + } + } + + PublishPairDataChanged(); + } + + public void HandleUserOffline(UserData user) + { + var registrationResult = _pairManager.MarkOffline(user); + if (registrationResult.Success) + { + _pendingCharacterData.TryRemove(user.UID, out _); + if (registrationResult.Value.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value); + } + + _mediator.Publish(new ClearProfileUserDataMessage(user)); + PublishPairDataChanged(); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("MarkOffline failed for {Uid}: {Error}", user.UID, registrationResult.Error); + } + } + + public void HandleUserPermissions(UserPermissionsDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Permission update received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + var previous = connection.OtherToSelfPermissions; + + var updateResult = _pairManager.UpdateOtherPermissions(dto); + if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); + return; + } + + PublishPairDataChanged(); + + if (previous.IsPaused() != dto.Permissions.IsPaused()) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + + if (connection.Ident is not null) + { + var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); + if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); + } + } + } + + if (!connection.IsPaused && connection.Ident is not null) + { + ReapplyLastKnownData(dto.User.UID, connection.Ident); + } + } + + public void HandleSelfPermissions(UserPermissionsDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Self permission update received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + var previous = connection.SelfToOtherPermissions; + + var updateResult = _pairManager.UpdateSelfPermissions(dto); + if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update self permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); + return; + } + + PublishPairDataChanged(); + + if (previous.IsPaused() != dto.Permissions.IsPaused()) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + + if (connection.Ident is not null) + { + var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); + if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); + } + } + } + + if (!connection.IsPaused && connection.Ident is not null) + { + ReapplyLastKnownData(dto.User.UID, connection.Ident); + } + } + + public void HandleUploadStatus(UserDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Upload status received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + if (connection.Ident is null) + { + return; + } + + var setResult = _handlerRegistry.SetUploading(new PairUniqueIdentifier(dto.User.UID), connection.Ident, true); + if (!setResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to set uploading for {Uid}: {Error}", dto.User.UID, setResult.Error); + } + } + + public void HandleCharacterData(OnlineUserCharaDataDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Character data received for unknown pair {Uid}, queued for later.", dto.User.UID); + } + _pendingCharacterData[dto.User.UID] = dto; + return; + } + + var connection = pairResult.Value; + _mediator.Publish(new EventMessage(new Event(connection.User, nameof(PairCoordinator), EventSeverity.Informational, "Received Character Data"))); + if (connection.Ident is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Character data received for {Uid} without ident, queued for later.", dto.User.UID); + } + _pendingCharacterData[dto.User.UID] = dto; + return; + } + + _pendingCharacterData.TryRemove(dto.User.UID, out _); + var registration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident); + var applyResult = _handlerRegistry.ApplyCharacterData(registration, dto); + if (!applyResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("ApplyCharacterData queued for {Uid}: {Error}", dto.User.UID, applyResult.Error); + } + } + + public void HandleProfile(UserDto dto) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs new file mode 100644 index 0000000..a0239c2 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -0,0 +1,1835 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.FileCache; +using LightlessSync.Interop.Ipc; +using LightlessSync.PlayerData.Factories; +using LightlessSync.PlayerData.Handlers; +using LightlessSync.Services; +using LightlessSync.Services.Events; +using LightlessSync.Services.Mediator; +using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Services.TextureCompression; +using LightlessSync.Utils; +using LightlessSync.WebAPI.Files; +using LightlessSync.WebAPI.Files.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; + +namespace LightlessSync.PlayerData.Pairs; + +public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject +{ + string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + + void Initialize(); + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); +} + +public interface IPairHandlerAdapterFactory +{ + IPairHandlerAdapter Create(string ident); +} + +internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter, IPairPerformanceSubject +{ + private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); + + private readonly DalamudUtilService _dalamudUtil; + private readonly FileDownloadManager _downloadManager; + private readonly FileCacheManager _fileDbManager; + private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; + private readonly IpcManager _ipcManager; + private readonly IHostApplicationLifetime _lifetime; + private readonly PlayerPerformanceService _playerPerformanceService; + private readonly PairProcessingLimiter _pairProcessingLimiter; + private readonly ServerConfigurationManager _serverConfigManager; + private readonly PluginWarningNotificationService _pluginWarningNotificationManager; + private readonly TextureDownscaleService _textureDownscaleService; + private readonly PairStateCache _pairStateCache; + private readonly PairManager _pairManager; + private Guid _currentDownloadOwnerToken; + private bool _downloadInProgress; + private CancellationTokenSource? _applicationCancellationTokenSource = new(); + private Guid _applicationId; + private Task? _applicationTask; + private CharacterData? _cachedData = null; + private GameObjectHandler? _charaHandler; + private readonly Dictionary _customizeIds = []; + private CombatData? _dataReceivedInDowntime; + private CancellationTokenSource? _downloadCancellationTokenSource = new(); + private bool _forceApplyMods = false; + private bool _forceFullReapply; + private bool _isVisible; + private Guid _penumbraCollection; + private readonly object _collectionGate = new(); + private bool _redrawOnNextApplication = false; + private readonly object _initializationGate = new(); + private readonly object _pauseLock = new(); + private Task _pauseTransitionTask = Task.CompletedTask; + private bool _pauseRequested; + private int _restoreRequested; + + public string Ident { get; } + public bool Initialized { get; private set; } + public bool ScheduledForDeletion { get; set; } + + public bool IsVisible + { + get => _isVisible; + private set + { + if (_isVisible != value) + { + _isVisible = value; + if (!_isVisible) + { + ResetRestoreState(); + DisableSync(); + ResetPenumbraCollection(reason: "VisibilityLost"); + } + else if (_charaHandler is not null && _charaHandler.Address != nint.Zero) + { + _ = EnsurePenumbraCollection(); + } + var user = GetPrimaryUserData(); + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), + EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); + Mediator.Publish(new RefreshUiMessage()); + Mediator.Publish(new VisibilityChange()); + } + } + } + + public long LastAppliedDataBytes { get; private set; } + public long LastAppliedDataTris { get; set; } = -1; + public long LastAppliedApproximateVRAMBytes { get; set; } = -1; + public long LastAppliedApproximateEffectiveVRAMBytes { get; set; } = -1; + public CharacterData? LastReceivedCharacterData { get; private set; } + + public PairHandlerAdapter( + ILogger logger, + LightlessMediator mediator, + PairManager pairManager, + string ident, + GameObjectHandlerFactory gameObjectHandlerFactory, + IpcManager ipcManager, + FileDownloadManager transferManager, + PluginWarningNotificationService pluginWarningNotificationManager, + DalamudUtilService dalamudUtil, + IHostApplicationLifetime lifetime, + FileCacheManager fileDbManager, + PlayerPerformanceService playerPerformanceService, + PairProcessingLimiter pairProcessingLimiter, + ServerConfigurationManager serverConfigManager, + TextureDownscaleService textureDownscaleService, + PairStateCache pairStateCache) : base(logger, mediator) + { + _pairManager = pairManager; + Ident = ident; + _gameObjectHandlerFactory = gameObjectHandlerFactory; + _ipcManager = ipcManager; + _downloadManager = transferManager; + _pluginWarningNotificationManager = pluginWarningNotificationManager; + _dalamudUtil = dalamudUtil; + _lifetime = lifetime; + _fileDbManager = fileDbManager; + _playerPerformanceService = playerPerformanceService; + _pairProcessingLimiter = pairProcessingLimiter; + _serverConfigManager = serverConfigManager; + _textureDownscaleService = textureDownscaleService; + _pairStateCache = pairStateCache; + LastAppliedDataBytes = -1; + } + + public void Initialize() + { + EnsureInitialized(); + } + + private void EnsureInitialized() + { + if (Initialized) + { + return; + } + + lock (_initializationGate) + { + if (Initialized) + { + return; + } + + var user = GetPrimaryUserData(); + if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 + || LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) + { + _forceApplyMods = true; + } + + Mediator.Subscribe(this, _ => FrameworkUpdate()); + Mediator.Subscribe(this, _ => + { + _downloadCancellationTokenSource?.CancelDispose(); + _charaHandler?.Invalidate(); + IsVisible = false; + }); + Mediator.Subscribe(this, _ => + { + ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraInitialized"); + if (!IsVisible && _charaHandler is not null) + { + PlayerName = string.Empty; + _charaHandler.Dispose(); + _charaHandler = null; + } + EnableSync(); + }); + Mediator.Subscribe(this, _ => ResetPenumbraCollection(releaseFromPenumbra: false, reason: "PenumbraDisposed")); + Mediator.Subscribe(this, msg => + { + if (msg.GameObjectHandler == _charaHandler) + { + _redrawOnNextApplication = true; + } + }); + Mediator.Subscribe(this, _ => EnableSync()); + Mediator.Subscribe(this, _ => DisableSync()); + Mediator.Subscribe(this, _ => EnableSync()); + Mediator.Subscribe(this, _ => DisableSync()); + Mediator.Subscribe(this, _ => DisableSync()); + Mediator.Subscribe(this, _ => EnableSync()); + Mediator.Subscribe(this, _ => DisableSync()); + Mediator.Subscribe(this, _ => EnableSync()); + Mediator.Subscribe(this, _ => DisableSync()); + Mediator.Subscribe(this, _ => EnableSync()); + Mediator.Subscribe(this, msg => + { + if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) + { + return; + } + + if (_downloadManager.CurrentOwnerToken.HasValue + && _downloadManager.CurrentOwnerToken == _currentDownloadOwnerToken) + { + TryApplyQueuedData(); + } + }); + + Initialized = true; + } + } + + private IReadOnlyList GetCurrentPairs() + { + return _pairManager.GetPairsByIdent(Ident); + } + + private PairConnection? GetPrimaryPair() + { + var pairs = GetCurrentPairs(); + var direct = pairs.FirstOrDefault(p => p.IsDirectlyPaired); + if (direct is not null) + { + return direct; + } + + var online = pairs.FirstOrDefault(p => p.IsOnline); + if (online is not null) + { + return online; + } + + return pairs.FirstOrDefault(); + } + + private UserData GetPrimaryUserData() + { + return GetPrimaryPair()?.User ?? new UserData(Ident); + } + + private string GetPrimaryAliasOrUid() + { + var pair = GetPrimaryPair(); + if (pair?.User is null) + { + return Ident; + } + + return string.IsNullOrEmpty(pair.User.AliasOrUID) ? Ident : pair.User.AliasOrUID; + } + + private string GetPrimaryAliasOrUidSafe() + { + try + { + return GetPrimaryAliasOrUid(); + } + catch + { + return Ident; + } + } + + private UserData GetPrimaryUserDataSafe() + { + try + { + return GetPrimaryUserData(); + } + catch + { + return new UserData(Ident); + } + } + + private string GetLogIdentifier() + { + var alias = GetPrimaryAliasOrUidSafe(); + return string.Equals(alias, Ident, StringComparison.Ordinal) ? alias : $"{alias} ({Ident})"; + } + + private Guid EnsurePenumbraCollection() + { + if (!IsVisible) + { + return Guid.Empty; + } + + if (_penumbraCollection != Guid.Empty) + { + return _penumbraCollection; + } + + lock (_collectionGate) + { + if (_penumbraCollection != Guid.Empty) + { + return _penumbraCollection; + } + + var cached = _pairStateCache.TryGetTemporaryCollection(Ident); + if (cached.HasValue && cached.Value != Guid.Empty) + { + _penumbraCollection = cached.Value; + return _penumbraCollection; + } + + if (!_ipcManager.Penumbra.APIAvailable) + { + return Guid.Empty; + } + + var user = GetPrimaryUserDataSafe(); + var uid = !string.IsNullOrEmpty(user.UID) ? user.UID : Ident; + var created = _ipcManager.Penumbra.CreateTemporaryCollectionAsync(Logger, uid) + .ConfigureAwait(false).GetAwaiter().GetResult(); + if (created != Guid.Empty) + { + _penumbraCollection = created; + _pairStateCache.StoreTemporaryCollection(Ident, created); + } + + return _penumbraCollection; + } + } + + private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null) + { + Guid toRelease = Guid.Empty; + lock (_collectionGate) + { + if (_penumbraCollection != Guid.Empty) + { + toRelease = _penumbraCollection; + _penumbraCollection = Guid.Empty; + } + } + + var cached = _pairStateCache.ClearTemporaryCollection(Ident); + if (cached.HasValue && cached.Value != Guid.Empty) + { + toRelease = cached.Value; + } + + if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable) + { + return; + } + + try + { + var applicationId = Guid.NewGuid(); + Logger.LogTrace("[{applicationId}] Removing temp collection {CollectionId} for {handler} ({reason})", applicationId, toRelease, GetLogIdentifier(), reason ?? "Cleanup"); + _ipcManager.Penumbra.RemoveTemporaryCollectionAsync(Logger, applicationId, toRelease).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to remove temporary Penumbra collection for {handler}", GetLogIdentifier()); + } + } + + private bool AnyPair(Func predicate) + { + return GetCurrentPairs().Any(predicate); + } + + private bool ShouldSkipDownscale() + { + return GetCurrentPairs().Any(p => p.IsDirectlyPaired && p.SelfToOtherPermissions.IsSticky()); + } + + private bool IsPaused() + { + var pairs = GetCurrentPairs(); + return pairs.Count > 0 && pairs.Any(p => p.IsPaused); + } + + bool IPairPerformanceSubject.IsPaused => IsPaused(); + + bool IPairPerformanceSubject.IsDirectlyPaired => AnyPair(p => p.IsDirectlyPaired); + + bool IPairPerformanceSubject.HasStickyPermissions => AnyPair(p => p.SelfToOtherPermissions.HasFlag(UserPermissions.Sticky)); + + UserData IPairPerformanceSubject.UserData => GetPrimaryUserData(); + + string IPairPerformanceSubject.PlayerName => PlayerName ?? GetPrimaryAliasOrUidSafe(); + private UserPermissions GetCombinedPermissions() + { + var pairs = GetCurrentPairs(); + if (pairs.Count == 0) + { + return UserPermissions.NoneSet; + } + + var combined = pairs[0].SelfToOtherPermissions | pairs[0].OtherToSelfPermissions; + for (int i = 1; i < pairs.Count; i++) + { + var perms = pairs[i].SelfToOtherPermissions | pairs[i].OtherToSelfPermissions; + combined &= perms; + } + + return combined; + } + public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero; + public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero + ? uint.MaxValue + : ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId; + public string? PlayerName { get; private set; } + public string PlayerNameHash => Ident; + + public void ApplyData(CharacterData data) + { + EnsureInitialized(); + LastReceivedCharacterData = data; + ResetRestoreState(); + ApplyLastReceivedData(); + } + + public void LoadCachedCharacterData(CharacterData data) + { + if (data is null) + { + return; + } + + LastReceivedCharacterData = data; + ResetRestoreState(); + _cachedData = null; + _forceApplyMods = true; + _forceFullReapply = true; + LastAppliedDataBytes = -1; + LastAppliedDataTris = -1; + LastAppliedApproximateVRAMBytes = -1; + LastAppliedApproximateEffectiveVRAMBytes = -1; + } + + public void ApplyLastReceivedData(bool forced = false) + { + EnsureInitialized(); + if (LastReceivedCharacterData is null) + { + Logger.LogTrace("No cached data to apply for {Ident}", Ident); + if (forced) + { + EnsureRestoredStateWhileWaitingForData("ForcedReapplyWithoutCache", skipIfAlreadyRestored: false); + } + return; + } + + var shouldForce = forced || HasMissingCachedFiles(LastReceivedCharacterData); + + if (IsPaused()) + { + Logger.LogTrace("Permissions paused for {Ident}, skipping reapply", Ident); + return; + } + + if (shouldForce) + { + _forceApplyMods = true; + _cachedData = null; + _forceFullReapply = true; + LastAppliedDataBytes = -1; + LastAppliedDataTris = -1; + LastAppliedApproximateVRAMBytes = -1; + LastAppliedApproximateEffectiveVRAMBytes = -1; + } + + var sanitized = RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone()); + if (sanitized is null) + { + Logger.LogTrace("Sanitized data null for {Ident}", Ident); + return; + } + + _pairStateCache.Store(Ident, sanitized); + + if (!IsVisible) + { + Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); + _cachedData = sanitized; + _forceFullReapply = true; + return; + } + + ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce); + } + + private bool HasMissingCachedFiles(CharacterData characterData) + { + try + { + HashSet inspectedHashes = new(StringComparer.OrdinalIgnoreCase); + foreach (var replacements in characterData.FileReplacements.Values) + { + foreach (var replacement in replacements) + { + if (!string.IsNullOrEmpty(replacement.FileSwapPath)) + { + if (!File.Exists(replacement.FileSwapPath)) + { + Logger.LogTrace("Missing file swap path {Path} detected for {Handler}", replacement.FileSwapPath, GetLogIdentifier()); + return true; + } + continue; + } + + if (string.IsNullOrEmpty(replacement.Hash) || !inspectedHashes.Add(replacement.Hash)) + { + continue; + } + + var cacheEntry = _fileDbManager.GetFileCacheByHash(replacement.Hash); + if (cacheEntry is null) + { + Logger.LogTrace("Missing cached file {Hash} detected for {Handler}", replacement.Hash, GetLogIdentifier()); + return true; + } + + if (!File.Exists(cacheEntry.ResolvedFilepath)) + { + Logger.LogTrace("Cached file {Hash} missing on disk for {Handler}, removing cache entry", replacement.Hash, GetLogIdentifier()); + _fileDbManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath); + return true; + } + } + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to determine cache availability for {Handler}", GetLogIdentifier()); + } + + return false; + } + + private CharacterData? RemoveNotSyncedFiles(CharacterData? data) + { + Logger.LogTrace("Removing not synced files for {Ident}", Ident); + if (data is null) + { + return null; + } + + var permissions = GetCombinedPermissions(); + bool disableAnimations = permissions.IsDisableAnimations(); + bool disableVfx = permissions.IsDisableVFX(); + bool disableSounds = permissions.IsDisableSounds(); + + if (!(disableAnimations || disableVfx || disableSounds)) + { + return data; + } + + foreach (var objectKind in data.FileReplacements.Keys.ToList()) + { + var replacements = data.FileReplacements[objectKind]; + if (disableSounds) + { + replacements = replacements + .Where(f => !f.GamePaths.Any(p => p.EndsWith("scd", StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + if (disableAnimations) + { + replacements = replacements + .Where(f => !f.GamePaths.Any(p => + p.EndsWith("tmb", StringComparison.OrdinalIgnoreCase) || + p.EndsWith("pap", StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + if (disableVfx) + { + replacements = replacements + .Where(f => !f.GamePaths.Any(p => + p.EndsWith("atex", StringComparison.OrdinalIgnoreCase) || + p.EndsWith("avfx", StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + data.FileReplacements[objectKind] = replacements; + } + + return data; + } + + private void ResetRestoreState() + { + Volatile.Write(ref _restoreRequested, 0); + } + + private void EnsureRestoredStateWhileWaitingForData(string reason, bool skipIfAlreadyRestored = true) + { + if (!IsVisible || _charaHandler is null || _charaHandler.Address == nint.Zero) + { + return; + } + + if (_cachedData is not null || LastReceivedCharacterData is not null) + { + return; + } + + if (!skipIfAlreadyRestored) + { + ResetRestoreState(); + } + else if (Volatile.Read(ref _restoreRequested) == 1) + { + return; + } + + if (Interlocked.CompareExchange(ref _restoreRequested, 1, 0) != 0) + { + return; + } + + var applicationId = Guid.NewGuid(); + _ = Task.Run(async () => + { + try + { + Logger.LogDebug("[{applicationId}] Restoring vanilla state for {handler} while waiting for data ({reason})", applicationId, GetLogIdentifier(), reason); + await RevertToRestoredAsync(applicationId).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "[{applicationId}] Failed to restore vanilla state for {handler} ({reason})", applicationId, GetLogIdentifier(), reason); + ResetRestoreState(); + } + }); + } + + private static Dictionary> BuildFullChangeSet(CharacterData characterData) + { + var result = new Dictionary>(); + + foreach (var objectKind in Enum.GetValues()) + { + var changes = new HashSet(); + + if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0) + { + changes.Add(PlayerChanges.ModFiles); + if (objectKind == ObjectKind.Player) + { + changes.Add(PlayerChanges.ForcedRedraw); + } + } + + if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) + { + changes.Add(PlayerChanges.Glamourer); + } + + if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize)) + { + changes.Add(PlayerChanges.Customize); + } + + if (objectKind == ObjectKind.Player) + { + if (!string.IsNullOrEmpty(characterData.ManipulationData)) + { + changes.Add(PlayerChanges.ModManip); + changes.Add(PlayerChanges.ForcedRedraw); + } + + if (!string.IsNullOrEmpty(characterData.HeelsData)) + { + changes.Add(PlayerChanges.Heels); + } + + if (!string.IsNullOrEmpty(characterData.HonorificData)) + { + changes.Add(PlayerChanges.Honorific); + } + + if (!string.IsNullOrEmpty(characterData.MoodlesData)) + { + changes.Add(PlayerChanges.Moodles); + } + + if (!string.IsNullOrEmpty(characterData.PetNamesData)) + { + changes.Add(PlayerChanges.PetNames); + } + } + + if (changes.Count > 0) + { + result[objectKind] = changes; + } + } + + return result; + } + + private bool CanApplyNow() + { + return !_dalamudUtil.IsInCombat + && !_dalamudUtil.IsPerforming + && !_dalamudUtil.IsInInstance + && !_dalamudUtil.IsInCutscene + && !_dalamudUtil.IsInGpose + && _ipcManager.Penumbra.APIAvailable + && _ipcManager.Glamourer.APIAvailable; + } + + public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) + { + if (characterData is null) + { + Logger.LogWarning("[BASE-{appBase}] Received null character data, skipping application for {handler}", applicationBase, GetLogIdentifier()); + SetUploading(isUploading: false); + return; + } + + var user = GetPrimaryUserData(); + if (_dalamudUtil.IsInCombat) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: you are in combat, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + if (_dalamudUtil.IsPerforming) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: you are performing music, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + if (_dalamudUtil.IsInInstance) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: you are in an instance, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + if (_dalamudUtil.IsInCutscene) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: you are in a cutscene, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + if (_dalamudUtil.IsInGpose) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: you are in GPose, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + if (!_ipcManager.Penumbra.APIAvailable || !_ipcManager.Glamourer.APIAvailable) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: Penumbra or Glamourer is not available, deferring application"))); + Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier()); + _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); + SetUploading(isUploading: false); + return; + } + + var handlerReady = _charaHandler is not null && PlayerCharacter != IntPtr.Zero; + + if (!handlerReady) + { + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Warning, + "Cannot apply character data: Receiving Player is in an invalid state, deferring application"))); + Logger.LogDebug("[BASE-{appBase}] Received data but player was in invalid state, charaHandlerIsNull: {charaIsNull}, playerPointerIsNull: {ptrIsNull}", + applicationBase, _charaHandler == null, PlayerCharacter == IntPtr.Zero); + var hasDiffMods = characterData.CheckUpdatedData(applicationBase, _cachedData, Logger, + this, forceApplyCustomization, forceApplyMods: false) + .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); + _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; + _forceFullReapply = true; + _cachedData = characterData; + Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); + } + + SetUploading(isUploading: false); + + Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); + Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); + + if (handlerReady + && string.Equals(characterData.DataHash.Value, _cachedData?.DataHash.Value ?? string.Empty, StringComparison.Ordinal) + && !forceApplyCustomization && !_forceApplyMods) + { + return; + } + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + "Applying Character Data"))); + + var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); + + if (_forceFullReapply) + { + charaDataToUpdate = BuildFullChangeSet(characterData); + } + + if (handlerReady && _forceApplyMods) + { + _forceApplyMods = false; + } + + if (_redrawOnNextApplication && charaDataToUpdate.TryGetValue(ObjectKind.Player, out var player)) + { + player.Add(PlayerChanges.ForcedRedraw); + _redrawOnNextApplication = false; + } + + if (charaDataToUpdate.TryGetValue(ObjectKind.Player, out var playerChanges)) + { + _pluginWarningNotificationManager.NotifyForMissingPlugins(user, PlayerName!, playerChanges); + } + + Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); + + var forcesReapply = _forceFullReapply || forceApplyCustomization || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0; + + DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forcesReapply, forceApplyCustomization); + } + + public override string ToString() + { + var alias = GetPrimaryAliasOrUidSafe(); + return $"{alias}:{PlayerName ?? string.Empty}:{(PlayerCharacter != nint.Zero ? "HasChar" : "NoChar")}"; + } + + public void SetUploading(bool isUploading = true) + { + Logger.LogTrace("Setting {name} uploading {uploading}", GetPrimaryAliasOrUidSafe(), isUploading); + if (_charaHandler != null) + { + Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading)); + } + } + + public void SetPaused(bool paused) + { + lock (_pauseLock) + { + if (_pauseRequested == paused) + { + return; + } + + _pauseRequested = paused; + _pauseTransitionTask = _pauseTransitionTask + .ContinueWith(_ => paused ? PauseInternalAsync() : ResumeInternalAsync(), TaskScheduler.Default) + .Unwrap(); + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + SetUploading(isUploading: false); + var name = PlayerName; + var user = GetPrimaryUserDataSafe(); + var alias = GetPrimaryAliasOrUidSafe(); + Logger.LogDebug("Disposing {name} ({user})", name, alias); + try + { + Guid applicationId = Guid.NewGuid(); + _applicationCancellationTokenSource?.CancelDispose(); + _applicationCancellationTokenSource = null; + _downloadCancellationTokenSource?.CancelDispose(); + _downloadCancellationTokenSource = null; + _downloadManager.Dispose(); + _charaHandler?.Dispose(); + _charaHandler = null; + + if (!string.IsNullOrEmpty(name)) + { + Mediator.Publish(new EventMessage(new Event(name, user, nameof(PairHandlerAdapter), EventSeverity.Informational, "Disposing User"))); + } + + if (_lifetime.ApplicationStopping.IsCancellationRequested) return; + + if (_dalamudUtil is { IsZoning: false, IsInCutscene: false } && !string.IsNullOrEmpty(name)) + { + Logger.LogTrace("[{applicationId}] Restoring state for {name} ({user})", applicationId, name, alias); + Logger.LogDebug("[{applicationId}] Removing Temp Collection for {name} ({user})", applicationId, name, alias); + ResetPenumbraCollection(reason: nameof(Dispose)); + if (!IsVisible) + { + Logger.LogDebug("[{applicationId}] Restoring Glamourer for {name} ({user})", applicationId, name, alias); + _ipcManager.Glamourer.RevertByNameAsync(Logger, name, applicationId).GetAwaiter().GetResult(); + } + else + { + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(60)); + + var effectiveCachedData = _cachedData ?? _pairStateCache.TryLoad(Ident); + if (effectiveCachedData is not null) + { + _cachedData = effectiveCachedData; + } + + Logger.LogInformation("[{applicationId}] CachedData is null {isNull}, contains things: {contains}", + applicationId, _cachedData == null, _cachedData?.FileReplacements.Any() ?? false); + + foreach (KeyValuePair> item in _cachedData?.FileReplacements ?? []) + { + try + { + RevertCustomizationDataAsync(item.Key, name, applicationId, cts.Token).GetAwaiter().GetResult(); + } + catch (InvalidOperationException ex) + { + Logger.LogWarning(ex, "Failed disposing player (not present anymore?)"); + break; + } + } + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error on disposal of {name}", name); + } + finally + { + PlayerName = null; + _cachedData = null; + Logger.LogDebug("Disposing {name} complete", name); + } + } + + private async Task ApplyCustomizationDataAsync(Guid applicationId, KeyValuePair> changes, CharacterData charaData, CancellationToken token) + { + if (PlayerCharacter == nint.Zero) return; + var ptr = PlayerCharacter; + + var handler = changes.Key switch + { + ObjectKind.Player => _charaHandler!, + ObjectKind.Companion => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetCompanionPtr(ptr), isWatched: false).ConfigureAwait(false), + ObjectKind.MinionOrMount => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetMinionOrMountPtr(ptr), isWatched: false).ConfigureAwait(false), + ObjectKind.Pet => await _gameObjectHandlerFactory.Create(changes.Key, () => _dalamudUtil.GetPetPtr(ptr), isWatched: false).ConfigureAwait(false), + _ => throw new NotSupportedException("ObjectKind not supported: " + changes.Key) + }; + + try + { + if (handler.Address == nint.Zero) + { + return; + } + + Logger.LogDebug("[{applicationId}] Applying Customization Data for {handler}", applicationId, handler); + await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handler, applicationId, 30000, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + foreach (var change in changes.Value.OrderBy(p => (int)p)) + { + Logger.LogDebug("[{applicationId}] Processing {change} for {handler}", applicationId, change, handler); + switch (change) + { + case PlayerChanges.Customize: + if (charaData.CustomizePlusData.TryGetValue(changes.Key, out var customizePlusData)) + { + _customizeIds[changes.Key] = await _ipcManager.CustomizePlus.SetBodyScaleAsync(handler.Address, customizePlusData).ConfigureAwait(false); + } + else if (_customizeIds.TryGetValue(changes.Key, out var customizeId)) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + _customizeIds.Remove(changes.Key); + } + break; + + case PlayerChanges.Heels: + await _ipcManager.Heels.SetOffsetForPlayerAsync(handler.Address, charaData.HeelsData).ConfigureAwait(false); + break; + + case PlayerChanges.Honorific: + await _ipcManager.Honorific.SetTitleAsync(handler.Address, charaData.HonorificData).ConfigureAwait(false); + break; + + case PlayerChanges.Glamourer: + if (charaData.GlamourerData.TryGetValue(changes.Key, out var glamourerData)) + { + await _ipcManager.Glamourer.ApplyAllAsync(Logger, handler, glamourerData, applicationId, token).ConfigureAwait(false); + } + break; + + case PlayerChanges.Moodles: + await _ipcManager.Moodles.SetStatusAsync(handler.Address, charaData.MoodlesData).ConfigureAwait(false); + break; + + case PlayerChanges.PetNames: + await _ipcManager.PetNames.SetPlayerData(handler.Address, charaData.PetNamesData).ConfigureAwait(false); + break; + + case PlayerChanges.ForcedRedraw: + await _ipcManager.Penumbra.RedrawAsync(Logger, handler, applicationId, token).ConfigureAwait(false); + break; + + default: + break; + } + token.ThrowIfCancellationRequested(); + } + } + finally + { + if (handler != _charaHandler) handler.Dispose(); + } + } + + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forcePerformanceRecalc, bool forceApplyCustomization) + { + if (!updatedData.Any()) + { + if (forcePerformanceRecalc) + { + updatedData = BuildFullChangeSet(charaData); + } + + if (!updatedData.Any()) + { + Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); + _forceFullReapply = false; + return; + } + } + + var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); + var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); + + if (_downloadInProgress) + { + Logger.LogDebug("[BASE-{appBase}] Download already in progress for {handler}, queueing data", applicationBase, GetLogIdentifier()); + EnqueueDeferredCharacterData(charaData, forceApplyCustomization || _forceApplyMods); + return; + } + + _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); + var downloadToken = _downloadCancellationTokenSource.Token; + var downloadOwnerToken = Guid.NewGuid(); + _currentDownloadOwnerToken = downloadOwnerToken; + _downloadInProgress = true; + + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, forcePerformanceRecalc, downloadOwnerToken, downloadToken).ConfigureAwait(false); + } + + private Task? _pairDownloadTask; + + private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, + bool updateModdedPaths, bool updateManip, bool forcePerformanceRecalc, Guid downloadOwnerToken, CancellationToken downloadToken) + { + try + { + await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); + Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; + bool skipDownscaleForPair = ShouldSkipDownscale(); + var user = GetPrimaryUserData(); + + bool performedDownload = false; + + if (updateModdedPaths) + { + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + Logger.LogDebug("[BASE-{appBase}] Initial missing files for {handler}: {count}", applicationBase, GetLogIdentifier(), toDownloadReplacements.Count); + + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + { + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) + { + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); + await _pairDownloadTask.ConfigureAwait(false); + } + + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var currentHandler = _charaHandler; + var toDownloadFiles = await _downloadManager.InitiateDownloadList(currentHandler, toDownloadReplacements, downloadToken, downloadOwnerToken).ConfigureAwait(false); + Logger.LogDebug("[BASE-{appBase}] Download plan prepared for {handler}: {current} transfers, forbidden so far: {forbidden}", applicationBase, GetLogIdentifier(), toDownloadFiles.Count, _downloadManager.ForbiddenTransfers.Count); + + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) + { + _downloadManager.ClearDownload(); + MarkApplicationDeferred(charaData); + return; + } + + performedDownload = true; + + var handlerForDownload = currentHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); + + await _pairDownloadTask.ConfigureAwait(false); + + if (downloadToken.IsCancellationRequested) + { + Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + MarkApplicationDeferred(charaData); + return; + } + + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); + Logger.LogDebug("[BASE-{appBase}] Re-evaluating missing files for {handler}: {count} remaining after attempt {attempt}", applicationBase, GetLogIdentifier(), toDownloadReplacements.Count, attempts); + } + + if (!performedDownload) + { + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List())) + { + _downloadManager.ClearDownload(); + MarkApplicationDeferred(charaData); + return; + } + } + + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) + { + MarkApplicationDeferred(charaData); + return; + } + } + else if (forcePerformanceRecalc) + { + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List())) + { + MarkApplicationDeferred(charaData); + return; + } + + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) + { + MarkApplicationDeferred(charaData); + return; + } + } + + downloadToken.ThrowIfCancellationRequested(); + + var handlerForApply = _charaHandler; + if (handlerForApply is null || handlerForApply.Address == nint.Zero) + { + Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + MarkApplicationDeferred(charaData); + return; + } + + var appToken = _applicationCancellationTokenSource?.Token; + while ((!_applicationTask?.IsCompleted ?? false) + && !downloadToken.IsCancellationRequested + && (!appToken?.IsCancellationRequested ?? false)) + { + // block until current application is done + Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); + await Task.Delay(250).ConfigureAwait(false); + } + + if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) + { + MarkApplicationDeferred(charaData); + return; + } + + _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); + var token = _applicationCancellationTokenSource.Token; + + _forceFullReapply = false; + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); + } + catch (OperationCanceledException ex) when (downloadToken.IsCancellationRequested || ex.CancellationToken == downloadToken) + { + Logger.LogDebug("[BASE-{appBase}] Download cancelled for {handler}", applicationBase, GetLogIdentifier()); + MarkApplicationDeferred(charaData); + } + finally + { + _downloadInProgress = false; + } + } + + 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) + { + try + { + _applicationId = Guid.NewGuid(); + Logger.LogDebug("[BASE-{applicationId}] Starting application task for {handler}: {appId}", applicationBase, GetLogIdentifier(), _applicationId); + + Logger.LogDebug("[{applicationId}] Waiting for initial draw for for {handler}", _applicationId, handlerForApply); + await _dalamudUtil.WaitWhileCharacterIsDrawing(Logger, handlerForApply, _applicationId, 30000, token).ConfigureAwait(false); + + token.ThrowIfCancellationRequested(); + + Guid penumbraCollection = Guid.Empty; + if (updateModdedPaths || updateManip) + { + penumbraCollection = EnsurePenumbraCollection(); + if (penumbraCollection == Guid.Empty) + { + Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + MarkApplicationDeferred(charaData); + return; + } + } + + if (updateModdedPaths) + { + // ensure collection is set + var objIndex = await _dalamudUtil.RunOnFrameworkThread(() => + { + var gameObject = handlerForApply.GetGameObject(); + return gameObject?.ObjectIndex; + }).ConfigureAwait(false); + + if (!objIndex.HasValue) + { + Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); + MarkApplicationDeferred(charaData); + return; + } + + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); + + await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection, + moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); + LastAppliedDataBytes = -1; + foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) + { + if (LastAppliedDataBytes == -1) LastAppliedDataBytes = 0; + + LastAppliedDataBytes += path.Length; + } + } + + if (updateManip) + { + await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, _applicationId, penumbraCollection, charaData.ManipulationData).ConfigureAwait(false); + } + + token.ThrowIfCancellationRequested(); + + foreach (var kind in updatedData) + { + await ApplyCustomizationDataAsync(_applicationId, kind, charaData, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + } + + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) + { + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); + } + if (LastAppliedDataTris < 0) + { + await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); + } + + Logger.LogDebug("[{applicationId}] Application finished", _applicationId); + } + catch (OperationCanceledException ex) when (ex.CancellationToken == token || token.IsCancellationRequested) + { + Logger.LogDebug("[{applicationId}] Application cancelled via request token for {handler}", _applicationId, GetLogIdentifier()); + MarkApplicationDeferred(charaData); + } + catch (OperationCanceledException ex) + { + MarkApplicationDeferred(charaData); + Logger.LogDebug("[{applicationId}] Application deferred; redraw or apply operation cancelled ({reason}) for {handler}", _applicationId, ex.Message, GetLogIdentifier()); + } + catch (Exception ex) + { + if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + { + IsVisible = false; + MarkApplicationDeferred(charaData); + Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); + } + else + { + Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); + } + } + } + + private void FrameworkUpdate() + { + if (string.IsNullOrEmpty(PlayerName)) + { + var pc = _dalamudUtil.FindPlayerByNameHash(Ident); + if (pc == default((string, nint))) return; + Logger.LogDebug("One-Time Initializing {handler}", GetLogIdentifier()); + Initialize(pc.Name); + Logger.LogDebug("One-Time Initialized {handler}", GetLogIdentifier()); + Mediator.Publish(new EventMessage(new Event(PlayerName, GetPrimaryUserData(), nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Initializing User For Character {pc.Name}"))); + } + + if (_charaHandler?.Address != nint.Zero && !IsVisible && !_pauseRequested) + { + Guid appData = Guid.NewGuid(); + IsVisible = true; + if (_cachedData is not null) + { + var cachedData = _cachedData; + Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, cached data exists", appData, GetLogIdentifier(), IsVisible); + + _ = Task.Run(() => + { + try + { + ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "[BASE-{appBase}] Failed to apply cached character data for {handler}", appData, GetLogIdentifier()); + } + }); + } + else if (LastReceivedCharacterData is not null) + { + Logger.LogTrace("[BASE-{appBase}] {handler} visibility changed, now: {visi}, last received data exists", appData, GetLogIdentifier(), IsVisible); + + _ = Task.Run(() => + { + try + { + ApplyLastReceivedData(forced: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "[BASE-{appBase}] Failed to reapply last received data for {handler}", appData, GetLogIdentifier()); + } + }); + } + else + { + Logger.LogTrace("{handler} visibility changed, now: {visi}, no cached or received data exists", GetLogIdentifier(), IsVisible); + EnsureRestoredStateWhileWaitingForData("VisibleWithoutCachedData"); + } + } + else if (_charaHandler?.Address == nint.Zero && IsVisible) + { + IsVisible = false; + _charaHandler.Invalidate(); + _downloadCancellationTokenSource?.CancelDispose(); + _downloadCancellationTokenSource = null; + Logger.LogTrace("{handler} visibility changed, now: {visi}", GetLogIdentifier(), IsVisible); + } + + TryApplyQueuedData(); + } + + private void Initialize(string name) + { + PlayerName = name; + _charaHandler = _gameObjectHandlerFactory.Create(ObjectKind.Player, () => _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident), isWatched: false).GetAwaiter().GetResult(); + + var user = GetPrimaryUserData(); + if (!string.IsNullOrEmpty(user.UID)) + { + _serverConfigManager.AutoPopulateNoteForUid(user.UID, name); + } + + Mediator.Subscribe(this, async (_) => + { + if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return; + Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier()); + await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false); + }); + + Mediator.Subscribe(this, async (_) => + { + if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return; + Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier()); + await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false); + }); + } + + private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken) + { + nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident); + if (address == nint.Zero) return; + + var alias = GetPrimaryAliasOrUid(); + Logger.LogDebug("[{applicationId}] Reverting all Customization for {alias}/{name} {objectKind}", applicationId, alias, name, objectKind); + + if (_customizeIds.TryGetValue(objectKind, out var customizeId)) + { + _customizeIds.Remove(objectKind); + } + + if (objectKind == ObjectKind.Player) + { + using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Player, () => address, isWatched: false).ConfigureAwait(false); + tempHandler.CompareNameAndThrow(name); + Logger.LogDebug("[{applicationId}] Restoring Customization and Equipment for {alias}/{name}", applicationId, alias, name); + await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + tempHandler.CompareNameAndThrow(name); + Logger.LogDebug("[{applicationId}] Restoring Heels for {alias}/{name}", applicationId, alias, name); + await _ipcManager.Heels.RestoreOffsetForPlayerAsync(address).ConfigureAwait(false); + tempHandler.CompareNameAndThrow(name); + Logger.LogDebug("[{applicationId}] Restoring C+ for {alias}/{name}", applicationId, alias, name); + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + tempHandler.CompareNameAndThrow(name); + Logger.LogDebug("[{applicationId}] Restoring Honorific for {alias}/{name}", applicationId, alias, name); + await _ipcManager.Honorific.ClearTitleAsync(address).ConfigureAwait(false); + Logger.LogDebug("[{applicationId}] Restoring Moodles for {alias}/{name}", applicationId, alias, name); + await _ipcManager.Moodles.RevertStatusAsync(address).ConfigureAwait(false); + Logger.LogDebug("[{applicationId}] Restoring Pet Nicknames for {alias}/{name}", applicationId, alias, name); + await _ipcManager.PetNames.ClearPlayerData(address).ConfigureAwait(false); + } + else if (objectKind == ObjectKind.MinionOrMount) + { + var minionOrMount = await _dalamudUtil.GetMinionOrMountAsync(address).ConfigureAwait(false); + if (minionOrMount != nint.Zero) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.MinionOrMount, () => minionOrMount, isWatched: false).ConfigureAwait(false); + await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + } + } + else if (objectKind == ObjectKind.Pet) + { + var pet = await _dalamudUtil.GetPetAsync(address).ConfigureAwait(false); + if (pet != nint.Zero) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => pet, isWatched: false).ConfigureAwait(false); + await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + } + } + else if (objectKind == ObjectKind.Companion) + { + var companion = await _dalamudUtil.GetCompanionAsync(address).ConfigureAwait(false); + if (companion != nint.Zero) + { + await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); + using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false); + await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); + } + } + } + + private List TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token) + { + Stopwatch st = Stopwatch.StartNew(); + ConcurrentBag missingFiles = []; + moddedDictionary = []; + ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new(); + bool hasMigrationChanges = false; + bool skipDownscaleForPair = ShouldSkipDownscale(); + + try + { + var replacementList = charaData.FileReplacements.SelectMany(k => k.Value.Where(v => string.IsNullOrEmpty(v.FileSwapPath))).ToList(); + Parallel.ForEach(replacementList, new ParallelOptions() + { + CancellationToken = token, + MaxDegreeOfParallelism = 4 + }, + (item) => + { + token.ThrowIfCancellationRequested(); + var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash); + if (fileCache != null) + { + if (!File.Exists(fileCache.ResolvedFilepath)) + { + Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash); + _fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + fileCache = null; + } + } + + if (fileCache != null) + { + if (string.IsNullOrEmpty(new FileInfo(fileCache.ResolvedFilepath).Extension)) + { + hasMigrationChanges = true; + fileCache = _fileDbManager.MigrateFileHashToExtension(fileCache, item.GamePaths[0].Split(".")[^1]); + } + + foreach (var gamePath in item.GamePaths) + { + var preferredPath = skipDownscaleForPair + ? fileCache.ResolvedFilepath + : _textureDownscaleService.GetPreferredPath(item.Hash, fileCache.ResolvedFilepath); + outputDict[(gamePath, item.Hash)] = preferredPath; + } + } + else + { + Logger.LogTrace("Missing file: {hash}", item.Hash); + missingFiles.Add(item); + } + }); + + moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value); + + foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList()) + { + foreach (var gamePath in item.GamePaths) + { + Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath); + moddedDictionary[(gamePath, null)] = item.FileSwapPath; + } + } + } + catch (OperationCanceledException) + { + Logger.LogTrace("[BASE-{appBase}] Modded path calculation cancelled", applicationBase); + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "[BASE-{appBase}] Something went wrong during calculation replacements", applicationBase); + } + if (hasMigrationChanges) _fileDbManager.WriteOutFullCsv(); + st.Stop(); + Logger.LogDebug("[BASE-{appBase}] ModdedPaths calculated in {time}ms, missing files: {count}, total files: {total}", applicationBase, st.ElapsedMilliseconds, missingFiles.Count, moddedDictionary.Keys.Count); + return [.. missingFiles]; + } + + private async Task PauseInternalAsync() + { + try + { + Logger.LogDebug("Pausing handler {handler}", GetLogIdentifier()); + DisableSync(); + + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + { + IsVisible = false; + return; + } + + var applicationId = Guid.NewGuid(); + await RevertToRestoredAsync(applicationId).ConfigureAwait(false); + IsVisible = false; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to pause handler {handler}", GetLogIdentifier()); + } + } + + private async Task ResumeInternalAsync() + { + try + { + Logger.LogDebug("Resuming handler {handler}", GetLogIdentifier()); + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + { + return; + } + + if (!IsVisible) + { + IsVisible = true; + } + + if (LastReceivedCharacterData is not null) + { + ApplyLastReceivedData(forced: true); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to resume handler {handler}", GetLogIdentifier()); + } + } + + private async Task RevertToRestoredAsync(Guid applicationId) + { + if (_charaHandler is null || _charaHandler.Address == nint.Zero) + { + return; + } + + try + { + var gameObject = await _dalamudUtil.RunOnFrameworkThread(() => _charaHandler.GetGameObject()).ConfigureAwait(false); + if (gameObject is not Dalamud.Game.ClientState.Objects.Types.ICharacter character) + { + return; + } + + if (_ipcManager.Penumbra.APIAvailable) + { + var penumbraCollection = EnsurePenumbraCollection(); + if (penumbraCollection != Guid.Empty) + { + await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false); + await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary()).ConfigureAwait(false); + await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false); + } + } + + var kinds = new HashSet(_customizeIds.Keys); + if (_cachedData is not null) + { + foreach (var kind in _cachedData.FileReplacements.Keys) + { + kinds.Add(kind); + } + } + + kinds.Add(ObjectKind.Player); + + var characterName = character.Name.TextValue; + if (string.IsNullOrEmpty(characterName)) + { + characterName = character.Name.ToString(); + } + if (string.IsNullOrEmpty(characterName)) + { + Logger.LogWarning("[{applicationId}] Failed to determine character name for {handler} while reverting", applicationId, GetLogIdentifier()); + return; + } + + foreach (var kind in kinds) + { + await RevertCustomizationDataAsync(kind, characterName, applicationId, CancellationToken.None).ConfigureAwait(false); + } + + _cachedData = null; + LastAppliedDataBytes = -1; + LastAppliedDataTris = -1; + LastAppliedApproximateVRAMBytes = -1; + LastAppliedApproximateEffectiveVRAMBytes = -1; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to revert handler {handler} during pause", GetLogIdentifier()); + } + } + + private void DisableSync() + { + _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate(); + _applicationCancellationTokenSource = _applicationCancellationTokenSource?.CancelRecreate(); + } + + private void EnableSync() + { + TryApplyQueuedData(); + } + + private void TryApplyQueuedData() + { + var pending = _dataReceivedInDowntime; + if (pending is null || !IsVisible) + { + return; + } + + if (!CanApplyNow()) + { + return; + } + + _dataReceivedInDowntime = null; + ApplyCharacterData(pending.ApplicationId, + pending.CharacterData, pending.Forced); + } + + private void MarkApplicationDeferred(CharacterData charaData) + { + _forceApplyMods = true; + _forceFullReapply = true; + _currentDownloadOwnerToken = Guid.Empty; + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + EnqueueDeferredCharacterData(charaData); + } + + private void EnqueueDeferredCharacterData(CharacterData charaData, bool forced = true) + { + try + { + _dataReceivedInDowntime = new(Guid.NewGuid(), charaData.DeepClone(), forced); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to queue deferred data for {handler}", GetLogIdentifier()); + } + } +} + +internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory +{ + private readonly ILoggerFactory _loggerFactory; + private readonly LightlessMediator _mediator; + private readonly PairManager _pairManager; + private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; + private readonly IpcManager _ipcManager; + private readonly FileDownloadManagerFactory _fileDownloadManagerFactory; + private readonly PluginWarningNotificationService _pluginWarningNotificationManager; + private readonly IServiceProvider _serviceProvider; + private readonly IHostApplicationLifetime _lifetime; + private readonly FileCacheManager _fileCacheManager; + private readonly PlayerPerformanceService _playerPerformanceService; + private readonly PairProcessingLimiter _pairProcessingLimiter; + private readonly ServerConfigurationManager _serverConfigManager; + private readonly TextureDownscaleService _textureDownscaleService; + private readonly PairStateCache _pairStateCache; + + public PairHandlerAdapterFactory( + ILoggerFactory loggerFactory, + LightlessMediator mediator, + PairManager pairManager, + GameObjectHandlerFactory gameObjectHandlerFactory, + IpcManager ipcManager, + FileDownloadManagerFactory fileDownloadManagerFactory, + PluginWarningNotificationService pluginWarningNotificationManager, + IServiceProvider serviceProvider, + IHostApplicationLifetime lifetime, + FileCacheManager fileCacheManager, + PlayerPerformanceService playerPerformanceService, + PairProcessingLimiter pairProcessingLimiter, + ServerConfigurationManager serverConfigManager, + TextureDownscaleService textureDownscaleService, + PairStateCache pairStateCache) + { + _loggerFactory = loggerFactory; + _mediator = mediator; + _pairManager = pairManager; + _gameObjectHandlerFactory = gameObjectHandlerFactory; + _ipcManager = ipcManager; + _fileDownloadManagerFactory = fileDownloadManagerFactory; + _pluginWarningNotificationManager = pluginWarningNotificationManager; + _serviceProvider = serviceProvider; + _lifetime = lifetime; + _fileCacheManager = fileCacheManager; + _playerPerformanceService = playerPerformanceService; + _pairProcessingLimiter = pairProcessingLimiter; + _serverConfigManager = serverConfigManager; + _textureDownscaleService = textureDownscaleService; + _pairStateCache = pairStateCache; + } + + public IPairHandlerAdapter Create(string ident) + { + var downloadManager = _fileDownloadManagerFactory.Create(); + var dalamudUtilService = _serviceProvider.GetRequiredService(); + return new PairHandlerAdapter( + _loggerFactory.CreateLogger(), + _mediator, + _pairManager, + ident, + _gameObjectHandlerFactory, + _ipcManager, + downloadManager, + _pluginWarningNotificationManager, + dalamudUtilService, + _lifetime, + _fileCacheManager, + _playerPerformanceService, + _pairProcessingLimiter, + _serverConfigManager, + _textureDownscaleService, + _pairStateCache); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs new file mode 100644 index 0000000..6c43119 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -0,0 +1,493 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.User; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +public sealed class PairHandlerRegistry : IDisposable +{ + private readonly object _gate = new(); + private readonly Dictionary _identToHandler = new(StringComparer.Ordinal); + private readonly Dictionary> _handlerToPairs = new(); + private readonly Dictionary _waitingRequests = new(StringComparer.Ordinal); + + private readonly IPairHandlerAdapterFactory _handlerFactory; + private readonly PairManager _pairManager; + private readonly PairStateCache _pairStateCache; + private readonly ILogger _logger; + + private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5); + private readonly TimeSpan _waitForHandlerGracePeriod = TimeSpan.FromMinutes(2); + + public PairHandlerRegistry( + IPairHandlerAdapterFactory handlerFactory, + PairManager pairManager, + PairStateCache pairStateCache, + ILogger logger) + { + _handlerFactory = handlerFactory; + _pairManager = pairManager; + _pairStateCache = pairStateCache; + _logger = logger; + } + + public int GetVisibleUsersCount() + { + lock (_gate) + { + return _handlerToPairs.Keys.Count(handler => handler.IsVisible); + } + } + + public bool IsIdentVisible(string ident) + { + lock (_gate) + { + return _identToHandler.TryGetValue(ident, out var handler) && handler.IsVisible; + } + } + + public PairOperationResult RegisterOnlinePair(PairRegistration registration) + { + if (registration.CharacterIdent is null) + { + return PairOperationResult.Fail($"Registration for {registration.PairIdent.UserId} missing ident."); + } + + IPairHandlerAdapter handler; + lock (_gate) + { + handler = GetOrAddHandler(registration.CharacterIdent); + handler.ScheduledForDeletion = false; + + if (!_handlerToPairs.TryGetValue(handler, out var set)) + { + set = new HashSet(); + _handlerToPairs[handler] = set; + } + + set.Add(registration.PairIdent); + } + + ApplyPauseStateForHandler(handler); + + if (handler.LastReceivedCharacterData is null) + { + var cachedData = _pairStateCache.TryLoad(registration.CharacterIdent); + if (cachedData is not null) + { + handler.LoadCachedCharacterData(cachedData); + } + } + + if (handler.LastReceivedCharacterData is not null && + (handler.LastAppliedApproximateVRAMBytes < 0 || handler.LastAppliedDataTris < 0)) + { + handler.ApplyLastReceivedData(forced: true); + } + + return PairOperationResult.Ok(registration.PairIdent); + } + + public PairOperationResult DeregisterOfflinePair(PairRegistration registration, bool forceDisposal = false) + { + if (registration.CharacterIdent is null) + { + return PairOperationResult.Fail($"Deregister for {registration.PairIdent.UserId} missing ident."); + } + + IPairHandlerAdapter? handler = null; + bool shouldScheduleRemoval = false; + bool shouldDisposeImmediately = false; + + lock (_gate) + { + if (!_identToHandler.TryGetValue(registration.CharacterIdent, out handler)) + { + return PairOperationResult.Fail($"Ident {registration.CharacterIdent} not registered."); + } + + if (_handlerToPairs.TryGetValue(handler, out var set)) + { + set.Remove(registration.PairIdent); + if (set.Count == 0) + { + if (forceDisposal) + { + shouldDisposeImmediately = true; + } + else + { + shouldScheduleRemoval = true; + handler.ScheduledForDeletion = true; + } + } + } + } + + if (shouldDisposeImmediately && handler is not null) + { + if (TryFinalizeHandlerRemoval(handler)) + { + handler.Dispose(); + } + } + else if (shouldScheduleRemoval && handler is not null) + { + _ = RemoveAfterGracePeriodAsync(handler); + } + + return PairOperationResult.Ok(registration.PairIdent); + } + + public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) + { + if (registration.CharacterIdent is null) + { + return PairOperationResult.Fail($"Character data received without ident for {registration.PairIdent.UserId}."); + } + + IPairHandlerAdapter? handler; + lock (_gate) + { + _identToHandler.TryGetValue(registration.CharacterIdent, out handler); + } + + if (handler is null) + { + var registerResult = RegisterOnlinePair(registration); + if (!registerResult.Success) + { + return PairOperationResult.Fail(registerResult.Error); + } + + lock (_gate) + { + _identToHandler.TryGetValue(registration.CharacterIdent, out handler); + } + } + + if (handler is null) + { + return PairOperationResult.Fail($"Handler not ready for {registration.PairIdent.UserId}."); + } + + handler.ApplyData(dto.CharaData); + return PairOperationResult.Ok(); + } + + public PairOperationResult ApplyLastReceivedData(PairUniqueIdentifier pairIdent, string ident, bool forced = false) + { + IPairHandlerAdapter? handler; + lock (_gate) + { + _identToHandler.TryGetValue(ident, out handler); + } + + if (handler is null) + { + return PairOperationResult.Fail($"Cannot reapply data: handler for {pairIdent.UserId} not found."); + } + + handler.ApplyLastReceivedData(forced); + return PairOperationResult.Ok(); + } + + public PairOperationResult SetUploading(PairUniqueIdentifier pairIdent, string ident, bool uploading) + { + IPairHandlerAdapter? handler; + lock (_gate) + { + _identToHandler.TryGetValue(ident, out handler); + } + + if (handler is null) + { + return PairOperationResult.Fail($"Cannot set uploading for {pairIdent.UserId}: handler not found."); + } + + handler.SetUploading(uploading); + return PairOperationResult.Ok(); + } + + public PairOperationResult SetPausedState(PairUniqueIdentifier pairIdent, string ident, bool paused) + { + IPairHandlerAdapter? handler; + lock (_gate) + { + _identToHandler.TryGetValue(ident, out handler); + } + + if (handler is null) + { + return PairOperationResult.Fail($"Cannot update pause state for {pairIdent.UserId}: handler not found."); + } + + _ = paused; // value reflected in pair manager already + // Recalculate pause state against all registered pairs to ensure consistency across contexts. + ApplyPauseStateForHandler(handler); + return PairOperationResult.Ok(); + } + + public PairOperationResult> GetPairConnections(string ident) + { + IPairHandlerAdapter? handler; + HashSet? identifiers = null; + + lock (_gate) + { + _identToHandler.TryGetValue(ident, out handler); + if (handler is not null) + { + _handlerToPairs.TryGetValue(handler, out identifiers); + } + } + + if (handler is null || identifiers is null) + { + return PairOperationResult>.Fail($"No handler registered for {ident}."); + } + + var list = new List<(PairUniqueIdentifier, PairConnection)>(); + foreach (var pairIdent in identifiers) + { + var result = _pairManager.GetPair(pairIdent.UserId); + if (result.Success) + { + list.Add((pairIdent, result.Value)); + } + } + + return PairOperationResult>.Ok(list); + } + + private void ApplyPauseStateForHandler(IPairHandlerAdapter handler) + { + var pairs = _pairManager.GetPairsByIdent(handler.Ident); + bool paused = pairs.Any(p => p.SelfToOtherPermissions.IsPaused() || p.OtherToSelfPermissions.IsPaused()); + handler.SetPaused(paused); + } + + internal bool TryGetHandler(string ident, out IPairHandlerAdapter? handler) + { + lock (_gate) + { + var success = _identToHandler.TryGetValue(ident, out var resolved); + handler = resolved; + return success; + } + } + + internal IReadOnlyList GetHandlerSnapshot() + { + lock (_gate) + { + return _identToHandler.Values.Distinct().ToList(); + } + } + + internal IReadOnlyCollection GetRegisteredPairs(IPairHandlerAdapter handler) + { + lock (_gate) + { + if (_handlerToPairs.TryGetValue(handler, out var pairs)) + { + return pairs.ToList(); + } + } + + return Array.Empty(); + } + + internal void ReapplyAll(bool forced = false) + { + var handlers = GetHandlerSnapshot(); + foreach (var handler in handlers) + { + try + { + handler.ApplyLastReceivedData(forced); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to reapply cached data for {Ident}", handler.Ident); + } + } + } + } + + internal void ResetAllHandlers() + { + List handlers; + lock (_gate) + { + handlers = _identToHandler.Values.Distinct().ToList(); + _identToHandler.Clear(); + _handlerToPairs.Clear(); + + foreach (var pending in _waitingRequests.Values) + { + pending.Cancel(); + pending.Dispose(); + } + + _waitingRequests.Clear(); + } + + foreach (var handler in handlers) + { + try + { + handler.Dispose(); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to dispose handler for {Ident}", handler.Ident); + } + } + } + } + + public void Dispose() + { + List handlers; + lock (_gate) + { + handlers = _identToHandler.Values.Distinct().ToList(); + _identToHandler.Clear(); + _handlerToPairs.Clear(); + foreach (var kv in _waitingRequests.Values) + { + kv.Cancel(); + } + _waitingRequests.Clear(); + } + + foreach (var handler in handlers) + { + handler.Dispose(); + } + } + + private IPairHandlerAdapter GetOrAddHandler(string ident) + { + if (_identToHandler.TryGetValue(ident, out var handler)) + { + return handler; + } + + handler = _handlerFactory.Create(ident); + _identToHandler[ident] = handler; + _handlerToPairs[handler] = new HashSet(); + return handler; + } + + private void EnsureInitialized(IPairHandlerAdapter handler) + { + if (handler.Initialized) + { + return; + } + + try + { + handler.Initialize(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize handler for {Ident}", handler.Ident); + } + } + + private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler) + { + try + { + await Task.Delay(_deletionGracePeriod).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + return; + } + + if (TryFinalizeHandlerRemoval(handler)) + { + handler.Dispose(); + } + } + + private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler) + { + lock (_gate) + { + if (!_handlerToPairs.TryGetValue(handler, out var set) || set.Count > 0) + { + handler.ScheduledForDeletion = false; + return false; + } + + _handlerToPairs.Remove(handler); + _identToHandler.Remove(handler.Ident); + + if (_waitingRequests.TryGetValue(handler.Ident, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + _waitingRequests.Remove(handler.Ident); + } + + return true; + } + } + + private async Task WaitThenApplyDataAsync(PairRegistration registration, OnlineUserCharaDataDto dto, CancellationTokenSource cts) + { + var token = cts.Token; + try + { + while (!token.IsCancellationRequested) + { + IPairHandlerAdapter? handler; + lock (_gate) + { + _identToHandler.TryGetValue(registration.CharacterIdent!, out handler); + } + + if (handler is not null && handler.Initialized) + { + handler.ApplyData(dto.CharaData); + break; + } + + await Task.Delay(TimeSpan.FromMilliseconds(500), token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // expected + } + finally + { + lock (_gate) + { + if (_waitingRequests.TryGetValue(registration.CharacterIdent!, out var existing) && existing == cts) + { + _waitingRequests.Remove(registration.CharacterIdent!); + } + } + + cts.Dispose(); + } + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs new file mode 100644 index 0000000..1e0e359 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.API.Data; +using LightlessSync.API.Dto.Group; +using LightlessSync.Services.Events; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Models; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +public sealed class PairLedger : DisposableMediatorSubscriberBase +{ + private readonly PairManager _pairManager; + private readonly PairHandlerRegistry _registry; + private readonly ILogger _logger; + private readonly object _metricsGate = new(); + private CancellationTokenSource? _ensureMetricsCts; + + public PairLedger( + ILogger logger, + LightlessMediator mediator, + PairManager pairManager, + PairHandlerRegistry registry) : base(logger, mediator) + { + _pairManager = pairManager; + _registry = registry; + _logger = logger; + + Mediator.Subscribe(this, _ => ReapplyAll(forced: true)); + Mediator.Subscribe(this, _ => ReapplyAll()); + Mediator.Subscribe(this, _ => ReapplyAll(forced: true)); + Mediator.Subscribe(this, _ => ReapplyAll(forced: true)); + Mediator.Subscribe(this, _ => Reset()); + Mediator.Subscribe(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2))); + Mediator.Subscribe(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2))); + Mediator.Subscribe(this, _ => ScheduleEnsureMetrics(TimeSpan.FromSeconds(2))); + Mediator.Subscribe(this, _ => EnsureMetricsForVisiblePairs()); + } + + public bool IsPairVisible(PairUniqueIdentifier pairIdent) + { + var connectionResult = _pairManager.GetPair(pairIdent.UserId); + if (!connectionResult.Success) + { + return false; + } + + var connection = connectionResult.Value; + if (connection.Ident is null) + { + return false; + } + + return _registry.IsIdentVisible(connection.Ident); + } + + public IPairHandlerAdapter? GetHandler(PairUniqueIdentifier pairIdent) + { + var connectionResult = _pairManager.GetPair(pairIdent.UserId); + if (!connectionResult.Success) + { + return null; + } + + var connection = connectionResult.Value; + if (connection.Ident is null) + { + return null; + } + + return _registry.TryGetHandler(connection.Ident, out var handler) ? handler : null; + } + + public IReadOnlyList GetVisiblePairs() + { + return _pairManager.GetAllPairs() + .Select(kv => kv.Value) + .Where(connection => connection.Ident is not null && _registry.IsIdentVisible(connection.Ident)) + .ToList(); + } + + public IReadOnlyList GetAllGroupInfos() + { + return _pairManager.GetAllGroups() + .Select(kv => kv.Value.GroupFullInfo) + .ToList(); + } + + public IReadOnlyDictionary GetAllSyncshells() + { + return _pairManager.GetAllGroups(); + } + + public void ReapplyAll(bool forced = false) + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Reapplying cached data for all handlers (forced: {Forced})", forced); + } + + _registry.ReapplyAll(forced); + } + + public void ReapplyPair(PairUniqueIdentifier pairIdent, bool forced = false) + { + var connectionResult = _pairManager.GetPair(pairIdent.UserId); + if (!connectionResult.Success) + { + return; + } + + var connection = connectionResult.Value; + if (connection.Ident is null) + { + return; + } + + var result = _registry.ApplyLastReceivedData(pairIdent, connection.Ident, forced); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to reapply data for {UserId}: {Error}", pairIdent.UserId, result.Error); + } + } + + private void Reset() + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("Resetting pair handlers after disconnect."); + } + + CancelScheduledMetrics(); + } + + public IReadOnlyList GetAllEntries() + { + var groups = _pairManager.GetAllGroups(); + var list = new List(); + foreach (var (userId, connection) in _pairManager.GetAllPairs()) + { + var ident = new PairUniqueIdentifier(userId); + IPairHandlerAdapter? handler = null; + if (connection.Ident is not null) + { + _registry.TryGetHandler(connection.Ident, out handler); + } + + var groupInfos = connection.Groups.Keys + .Select(gid => + { + if (groups.TryGetValue(gid, out var shell)) + { + return shell.GroupFullInfo; + } + return null; + }) + .Where(dto => dto is not null) + .Cast() + .ToList(); + + list.Add(new PairDisplayEntry(ident, connection, groupInfos, handler)); + } + + return list; + } + + public bool TryGetEntry(PairUniqueIdentifier ident, out PairDisplayEntry? entry) + { + entry = null; + var connectionResult = _pairManager.GetPair(ident.UserId); + if (!connectionResult.Success) + { + return false; + } + + var connection = connectionResult.Value; + var groups = connection.Groups.Keys + .Select(gid => + { + var groupResult = _pairManager.GetGroup(gid); + return groupResult.Success ? groupResult.Value.GroupFullInfo : null; + }) + .Where(dto => dto is not null) + .Cast() + .ToList(); + + IPairHandlerAdapter? handler = null; + if (connection.Ident is not null) + { + _registry.TryGetHandler(connection.Ident, out handler); + } + + entry = new PairDisplayEntry(ident, connection, groups, handler); + return true; + } + + private void ScheduleEnsureMetrics(TimeSpan? delay = null) + { + lock (_metricsGate) + { + _ensureMetricsCts?.Cancel(); + var cts = new CancellationTokenSource(); + _ensureMetricsCts = cts; + _ = Task.Run(async () => + { + try + { + if (delay is { } d && d > TimeSpan.Zero) + { + await Task.Delay(d, cts.Token).ConfigureAwait(false); + } + + EnsureMetricsForVisiblePairs(); + } + catch (OperationCanceledException) + { + // ignored + } + finally + { + lock (_metricsGate) + { + if (_ensureMetricsCts == cts) + { + _ensureMetricsCts = null; + } + } + + cts.Dispose(); + } + }); + } + } + + private void CancelScheduledMetrics() + { + lock (_metricsGate) + { + _ensureMetricsCts?.Cancel(); + _ensureMetricsCts = null; + } + } + + private void EnsureMetricsForVisiblePairs() + { + var handlers = _registry.GetHandlerSnapshot(); + foreach (var handler in handlers) + { + if (!handler.IsVisible) + { + continue; + } + + if (handler.LastReceivedCharacterData is null) + { + continue; + } + + if (handler.LastAppliedApproximateVRAMBytes >= 0 + && handler.LastAppliedDataTris >= 0 + && handler.LastAppliedApproximateEffectiveVRAMBytes >= 0) + { + continue; + } + + try + { + handler.ApplyLastReceivedData(forced: true); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident); + } + } + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + CancelScheduledMetrics(); + } + + base.Dispose(disposing); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairManager.cs b/LightlessSync/PlayerData/Pairs/PairManager.cs index 2044db0..adbe5b8 100644 --- a/LightlessSync/PlayerData/Pairs/PairManager.cs +++ b/LightlessSync/PlayerData/Pairs/PairManager.cs @@ -1,497 +1,575 @@ -using Dalamud.Plugin.Services; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using LightlessSync.API.Data; -using LightlessSync.API.Data.Comparer; -using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; -using LightlessSync.LightlessConfiguration; -using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.PlayerData.Factories; -using LightlessSync.Services; - -using LightlessSync.Services.Events; -using LightlessSync.Services.Mediator; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Threading; -using System.Threading.Tasks; namespace LightlessSync.PlayerData.Pairs; -public sealed class PairManager : DisposableMediatorSubscriberBase +public sealed class PairManager { - private readonly ConcurrentDictionary _allClientPairs = new(UserDataComparer.Instance); - private readonly ConcurrentDictionary _allGroups = new(GroupDataComparer.Instance); - private readonly LightlessConfigService _configurationService; - private readonly IContextMenu _dalamudContextMenu; - private readonly PairFactory _pairFactory; - private Lazy> _directPairsInternal; - private Lazy>> _groupPairsInternal; - private Lazy>> _pairsWithGroupsInternal; - private readonly PairProcessingLimiter _pairProcessingLimiter; - private readonly ConcurrentQueue<(Pair Pair, OnlineUserIdentDto? Ident)> _pairCreationQueue = new(); - private CancellationTokenSource _pairCreationCts = new(); - private int _pairCreationProcessorRunning; + private readonly object _gate = new(); + private readonly Dictionary _pairs = new(StringComparer.Ordinal); + private readonly Dictionary _groups = new(StringComparer.Ordinal); - public PairManager(ILogger logger, PairFactory pairFactory, - LightlessConfigService configurationService, LightlessMediator mediator, - IContextMenu dalamudContextMenu, PairProcessingLimiter pairProcessingLimiter) : base(logger, mediator) + public PairConnection? LastAddedUser { get; private set; } + + public IReadOnlyDictionary GetAllPairs() { - _pairFactory = pairFactory; - _configurationService = configurationService; - _dalamudContextMenu = dalamudContextMenu; - _pairProcessingLimiter = pairProcessingLimiter; - Mediator.Subscribe(this, (_) => ClearPairs()); - Mediator.Subscribe(this, (_) => ReapplyPairData()); - _directPairsInternal = DirectPairsLazy(); - _groupPairsInternal = GroupPairsLazy(); - _pairsWithGroupsInternal = PairsWithGroupsLazy(); - - _dalamudContextMenu.OnMenuOpened += DalamudContextMenuOnOnOpenGameObjectContextMenu; - } - - public List DirectPairs => _directPairsInternal.Value; - - public Dictionary> GroupPairs => _groupPairsInternal.Value; - public Dictionary Groups => _allGroups.ToDictionary(k => k.Key, k => k.Value); - public Pair? LastAddedUser { get; internal set; } - public Dictionary> PairsWithGroups => _pairsWithGroupsInternal.Value; - - public void AddGroup(GroupFullInfoDto dto) - { - _allGroups[dto.Group] = dto; - RecreateLazy(); - } - - public void AddGroupPair(GroupPairFullInfoDto dto) - { - if (!_allClientPairs.ContainsKey(dto.User)) - _allClientPairs[dto.User] = _pairFactory.Create(new UserFullPairDto(dto.User, API.Data.Enum.IndividualPairStatus.None, - [dto.Group.GID], dto.SelfToOtherPermissions, dto.OtherToSelfPermissions)); - else _allClientPairs[dto.User].UserPair.Groups.Add(dto.GID); - RecreateLazy(); - } - - public Pair? GetPairByUID(string uid) - { - var existingPair = _allClientPairs.FirstOrDefault(f => f.Key.UID == uid); - if (!Equals(existingPair, default(KeyValuePair))) + lock (_gate) { - return existingPair.Value; + return new Dictionary(_pairs); } - - return null; } - public void AddUserPair(UserFullPairDto dto) + public IReadOnlyDictionary GetAllGroups() { - if (!_allClientPairs.ContainsKey(dto.User)) + lock (_gate) { - _allClientPairs[dto.User] = _pairFactory.Create(dto); + return new Dictionary(_groups); } - else - { - _allClientPairs[dto.User].UserPair.IndividualPairStatus = dto.IndividualPairStatus; - _allClientPairs[dto.User].ApplyLastReceivedData(); - } - - RecreateLazy(); } - public void AddUserPair(UserPairDto dto, bool addToLastAddedUser = true) + public PairConnection? GetLastAddedUser() { - if (!_allClientPairs.ContainsKey(dto.User)) + lock (_gate) { - _allClientPairs[dto.User] = _pairFactory.Create(dto); + return LastAddedUser; } - else - { - addToLastAddedUser = false; - } - - _allClientPairs[dto.User].UserPair.IndividualPairStatus = dto.IndividualPairStatus; - _allClientPairs[dto.User].UserPair.OwnPermissions = dto.OwnPermissions; - _allClientPairs[dto.User].UserPair.OtherPermissions = dto.OtherPermissions; - if (addToLastAddedUser) - LastAddedUser = _allClientPairs[dto.User]; - _allClientPairs[dto.User].ApplyLastReceivedData(); - RecreateLazy(); } - public void ClearPairs() + public void ClearLastAddedUser() { - Logger.LogDebug("Clearing all Pairs"); - ResetPairCreationQueue(); - DisposePairs(); - _allClientPairs.Clear(); - _allGroups.Clear(); - RecreateLazy(); - } - - public List GetOnlineUserPairs() => _allClientPairs.Where(p => !string.IsNullOrEmpty(p.Value.GetPlayerNameHash())).Select(p => p.Value).ToList(); - - public int GetVisibleUserCount() => _allClientPairs.Count(p => p.Value.IsVisible); - - public List GetVisibleUsers() => [.. _allClientPairs.Where(p => p.Value.IsVisible).Select(p => p.Key)]; - - public void MarkPairOffline(UserData user) - { - if (_allClientPairs.TryGetValue(user, out var pair)) + lock (_gate) { - Mediator.Publish(new ClearProfileUserDataMessage(pair.UserData)); - pair.MarkOffline(); + LastAddedUser = null; } - - RecreateLazy(); } - public void MarkPairOnline(OnlineUserIdentDto dto, bool sendNotif = true) + public void ClearAll() { - if (!_allClientPairs.ContainsKey(dto.User)) throw new InvalidOperationException("No user found for " + dto); - - Mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - - var pair = _allClientPairs[dto.User]; - if (pair.HasCachedPlayer) + lock (_gate) { - RecreateLazy(); - return; + _pairs.Clear(); + _groups.Clear(); + LastAddedUser = null; } - - if (sendNotif && _configurationService.Current.ShowOnlineNotifications - && (_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs && pair.IsDirectlyPaired && !pair.IsOneSidedPair - || !_configurationService.Current.ShowOnlineNotificationsOnlyForIndividualPairs) - && (_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs && !string.IsNullOrEmpty(pair.GetNote()) - || !_configurationService.Current.ShowOnlineNotificationsOnlyForNamedPairs)) - { - string? note = pair.GetNote(); - var msg = !string.IsNullOrEmpty(note) - ? $"{note} ({pair.UserData.AliasOrUID}) is now online" - : $"{pair.UserData.AliasOrUID} is now online"; - Mediator.Publish(new NotificationMessage("User online", msg, NotificationType.Info, TimeSpan.FromSeconds(5))); - } - - QueuePairCreation(pair, dto); - - RecreateLazy(); } - public void ReceiveCharaData(OnlineUserCharaDataDto dto) + public PairOperationResult GetPair(string userId) { - if (!_allClientPairs.TryGetValue(dto.User, out var pair)) throw new InvalidOperationException("No user found for " + dto.User); - - Mediator.Publish(new EventMessage(new Event(pair.UserData, nameof(PairManager), EventSeverity.Informational, "Received Character Data"))); - _allClientPairs[dto.User].ApplyData(dto); - } - - public void RemoveGroup(GroupData data) - { - _allGroups.TryRemove(data, out _); - - foreach (var item in _allClientPairs.ToList()) + lock (_gate) { - item.Value.UserPair.Groups.Remove(data.GID); - - if (!item.Value.HasAnyConnection()) + if (_pairs.TryGetValue(userId, out var connection)) { - item.Value.MarkOffline(); - _allClientPairs.TryRemove(item.Key, out _); - } - } - - RecreateLazy(); - } - - public void RemoveGroupPair(GroupPairDto dto) - { - if (_allClientPairs.TryGetValue(dto.User, out var pair)) - { - pair.UserPair.Groups.Remove(dto.Group.GID); - - if (!pair.HasAnyConnection()) - { - pair.MarkOffline(); - _allClientPairs.TryRemove(dto.User, out _); - } - } - - RecreateLazy(); - } - - public void RemoveUserPair(UserDto dto) - { - if (_allClientPairs.TryGetValue(dto.User, out var pair)) - { - pair.UserPair.IndividualPairStatus = API.Data.Enum.IndividualPairStatus.None; - - if (!pair.HasAnyConnection()) - { - pair.MarkOffline(); - _allClientPairs.TryRemove(dto.User, out _); - } - } - - RecreateLazy(); - } - - public void SetGroupInfo(GroupInfoDto dto) - { - _allGroups[dto.Group].Group = dto.Group; - _allGroups[dto.Group].Owner = dto.Owner; - _allGroups[dto.Group].GroupPermissions = dto.GroupPermissions; - - RecreateLazy(); - } - - public void UpdatePairPermissions(UserPermissionsDto dto) - { - if (!_allClientPairs.TryGetValue(dto.User, out var pair)) - { - throw new InvalidOperationException("No such pair for " + dto); - } - - if (pair.UserPair == null) throw new InvalidOperationException("No direct pair for " + dto); - - if (pair.UserPair.OtherPermissions.IsPaused() != dto.Permissions.IsPaused()) - { - Mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - } - - pair.UserPair.OtherPermissions = dto.Permissions; - - Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}", - pair.UserPair.OtherPermissions.IsPaused(), - pair.UserPair.OtherPermissions.IsDisableAnimations(), - pair.UserPair.OtherPermissions.IsDisableSounds(), - pair.UserPair.OtherPermissions.IsDisableVFX()); - - if (!pair.IsPaused) - pair.ApplyLastReceivedData(); - - RecreateLazy(); - } - - public void UpdateSelfPairPermissions(UserPermissionsDto dto) - { - if (!_allClientPairs.TryGetValue(dto.User, out var pair)) - { - throw new InvalidOperationException("No such pair for " + dto); - } - - if (pair.UserPair.OwnPermissions.IsPaused() != dto.Permissions.IsPaused()) - { - Mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - } - - pair.UserPair.OwnPermissions = dto.Permissions; - - Logger.LogTrace("Paused: {paused}, Anims: {anims}, Sounds: {sounds}, VFX: {vfx}", - pair.UserPair.OwnPermissions.IsPaused(), - pair.UserPair.OwnPermissions.IsDisableAnimations(), - pair.UserPair.OwnPermissions.IsDisableSounds(), - pair.UserPair.OwnPermissions.IsDisableVFX()); - - if (!pair.IsPaused) - pair.ApplyLastReceivedData(); - - RecreateLazy(); - } - - internal void ReceiveUploadStatus(UserDto dto) - { - if (_allClientPairs.TryGetValue(dto.User, out var existingPair) && existingPair.IsVisible) - { - existingPair.SetIsUploading(); - } - } - - internal void SetGroupPairStatusInfo(GroupPairUserInfoDto dto) - { - _allGroups[dto.Group].GroupPairUserInfos[dto.UID] = dto.GroupUserInfo; - RecreateLazy(); - } - - internal void SetGroupPermissions(GroupPermissionDto dto) - { - _allGroups[dto.Group].GroupPermissions = dto.Permissions; - RecreateLazy(); - } - - internal void SetGroupStatusInfo(GroupPairUserInfoDto dto) - { - _allGroups[dto.Group].GroupUserInfo = dto.GroupUserInfo; - RecreateLazy(); - } - - internal void UpdateGroupPairPermissions(GroupPairUserPermissionDto dto) - { - _allGroups[dto.Group].GroupUserPermissions = dto.GroupPairPermissions; - RecreateLazy(); - } - - internal void UpdateIndividualPairStatus(UserIndividualPairStatusDto dto) - { - if (_allClientPairs.TryGetValue(dto.User, out var pair)) - { - pair.UserPair.IndividualPairStatus = dto.IndividualPairStatus; - RecreateLazy(); - } - } - - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); - - ResetPairCreationQueue(); - _dalamudContextMenu.OnMenuOpened -= DalamudContextMenuOnOnOpenGameObjectContextMenu; - - DisposePairs(); - } - - private void DalamudContextMenuOnOnOpenGameObjectContextMenu(Dalamud.Game.Gui.ContextMenu.IMenuOpenedArgs args) - { - if (args.MenuType == Dalamud.Game.Gui.ContextMenu.ContextMenuType.Inventory) return; - if (!_configurationService.Current.EnableRightClickMenus) return; - - foreach (var pair in _allClientPairs.Where((p => p.Value.IsVisible))) - { - pair.Value.AddContextMenu(args); - } - } - - private Lazy> DirectPairsLazy() => new(() => _allClientPairs.Select(k => k.Value) - .Where(k => k.IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None).ToList()); - - private void DisposePairs() - { - Logger.LogDebug("Disposing all Pairs"); - Parallel.ForEach(_allClientPairs, item => - { - item.Value.MarkOffline(wait: false); - }); - - RecreateLazy(); - } - - private Lazy>> GroupPairsLazy() - { - return new Lazy>>(() => - { - Dictionary> outDict = []; - foreach (var group in _allGroups) - { - outDict[group.Value] = _allClientPairs.Select(p => p.Value).Where(p => p.UserPair.Groups.Exists(g => GroupDataComparer.Instance.Equals(group.Key, new(g)))).ToList(); - } - return outDict; - }); - } - - private Lazy>> PairsWithGroupsLazy() - { - return new Lazy>>(() => - { - Dictionary> outDict = []; - - foreach (var pair in _allClientPairs.Select(k => k.Value)) - { - outDict[pair] = _allGroups.Where(k => pair.UserPair.Groups.Contains(k.Key.GID, StringComparer.Ordinal)).Select(k => k.Value).ToList(); + return PairOperationResult.Ok(connection); } - return outDict; - }); - } - - private void QueuePairCreation(Pair pair, OnlineUserIdentDto? dto) - { - if (pair.HasCachedPlayer) - { - RecreateLazy(); - return; - } - - _pairCreationQueue.Enqueue((pair, dto)); - StartPairCreationProcessor(); - } - - private void StartPairCreationProcessor() - { - if (_pairCreationCts.IsCancellationRequested) - { - return; - } - - if (Interlocked.CompareExchange(ref _pairCreationProcessorRunning, 1, 0) == 0) - { - _ = Task.Run(ProcessPairCreationQueueAsync); + return PairOperationResult.Fail($"Pair {userId} not found."); } } - private async Task ProcessPairCreationQueueAsync() + public bool TryGetPair(string userId, [NotNullWhen(true)] out PairConnection? connection) { - try + lock (_gate) { - while (!_pairCreationCts.IsCancellationRequested) + return _pairs.TryGetValue(userId, out connection); + } + } + + public PairOperationResult GetGroup(string groupId) + { + lock (_gate) + { + if (_groups.TryGetValue(groupId, out var shell)) { - if (!_pairCreationQueue.TryDequeue(out var work)) + return PairOperationResult.Ok(shell); + } + + return PairOperationResult.Fail($"Group {groupId} not found."); + } + } + + public IReadOnlyList GetDirectPairs() + { + lock (_gate) + { + return _pairs.Values.Where(p => p.IsDirectlyPaired).ToList(); + } + } + + public IReadOnlyList GetPairsByIdent(string ident) + { + lock (_gate) + { + return _pairs.Values + .Where(p => p.Ident is not null && string.Equals(p.Ident, ident, StringComparison.Ordinal)) + .ToList(); + } + } + + public IReadOnlyList GetOwnedOrModeratedShells(string currentUserUid) + { + lock (_gate) + { + return _groups.Values + .Where(s => + string.Equals(s.GroupFullInfo.Owner.UID, currentUserUid, StringComparison.OrdinalIgnoreCase) + || s.GroupFullInfo.GroupUserInfo.HasFlag(GroupPairUserInfo.IsModerator)) + .ToList(); + } + } + + public PairOperationResult GetPairCombinedPermissions(string userId) + { + lock (_gate) + { + if (!_pairs.TryGetValue(userId, out var connection)) + { + return PairOperationResult.Fail($"Pair {userId} not found."); + } + + var combined = connection.SelfToOtherPermissions | connection.OtherToSelfPermissions; + return PairOperationResult.Ok(combined); + } + } + + public PairOperationResult MarkOnline(OnlineUserIdentDto dto) + { + lock (_gate) + { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + connection = GetOrCreatePair(dto.User); + } + + connection.SetOnline(dto.Ident); + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), dto.Ident)); + } + } + + public PairOperationResult MarkOffline(UserData user) + { + lock (_gate) + { + if (!_pairs.TryGetValue(user.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {user.UID} not found."); + } + + connection.SetOffline(); + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(user.UID), connection.Ident)); + } + } + + public PairOperationResult AddOrUpdateIndividual(UserPairDto dto, bool markAsLastAddedUser = true) + { + lock (_gate) + { + var connection = GetOrCreatePair(dto.User, out var created); + connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions); + connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus); + + if (connection.Ident is null) + { + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), null)); + } + + if (created && markAsLastAddedUser) + { + LastAddedUser = connection; + } + + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident)); + } + } + + public PairOperationResult AddOrUpdateIndividual(UserFullPairDto dto) + { + lock (_gate) + { + var connection = GetOrCreatePair(dto.User, out _); + connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions); + connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus); + + var removedGroups = connection.Groups.Keys.Where(k => !dto.Groups.Contains(k, StringComparer.Ordinal)).ToList(); + foreach (var groupId in removedGroups) + { + connection.RemoveGroupRelationship(groupId); + if (_groups.TryGetValue(groupId, out var shell)) { - break; + shell.Users.Remove(dto.User.UID); } - - try - { - await using var lease = await _pairProcessingLimiter.AcquireAsync(_pairCreationCts.Token).ConfigureAwait(false); - if (!work.Pair.HasCachedPlayer) - { - work.Pair.CreateCachedPlayer(work.Ident); - } - } - catch (OperationCanceledException) - { - break; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error creating cached player for {uid}", work.Pair.UserData.UID); - } - - RecreateLazy(); - await Task.Yield(); } - } - finally - { - Interlocked.Exchange(ref _pairCreationProcessorRunning, 0); - if (!_pairCreationQueue.IsEmpty && !_pairCreationCts.IsCancellationRequested) + + foreach (var groupId in dto.Groups) { - StartPairCreationProcessor(); + connection.EnsureGroupRelationship(groupId, null); + if (_groups.TryGetValue(groupId, out var shell)) + { + shell.Users[dto.User.UID] = connection; + } } + + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident)); } } - private void ResetPairCreationQueue() + public PairOperationResult RemoveIndividual(UserDto dto) { - _pairCreationCts.Cancel(); - while (_pairCreationQueue.TryDequeue(out _)) + lock (_gate) { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdateStatus(null); + var registration = TryRemovePairIfNoConnection(connection); + return PairOperationResult.Ok(registration); } - _pairCreationCts.Dispose(); - _pairCreationCts = new CancellationTokenSource(); - Interlocked.Exchange(ref _pairCreationProcessorRunning, 0); } - private void ReapplyPairData() + public PairOperationResult SetPairOtherToSelfPermissions(UserPermissionsDto dto) { - foreach (var pair in _allClientPairs.Select(k => k.Value)) + lock (_gate) { - pair.ApplyLastReceivedData(forced: true); + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdatePermissions(connection.SelfToOtherPermissions, dto.Permissions); + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident)); } } - private void RecreateLazy() + public PairOperationResult SetPairSelfToOtherPermissions(UserPermissionsDto dto) { - _directPairsInternal = DirectPairsLazy(); - _groupPairsInternal = GroupPairsLazy(); - _pairsWithGroupsInternal = PairsWithGroupsLazy(); - Mediator.Publish(new RefreshUiMessage()); + lock (_gate) + { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdatePermissions(dto.Permissions, connection.OtherToSelfPermissions); + return PairOperationResult.Ok(new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident)); + } } -} \ No newline at end of file + + public PairOperationResult SetIndividualStatus(UserIndividualPairStatusDto dto) + { + lock (_gate) + { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus); + _ = TryRemovePairIfNoConnection(connection); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult AddOrUpdateGroupPair(GroupPairFullInfoDto dto) + { + lock (_gate) + { + var shell = GetOrCreateShell(dto.Group); + var connection = GetOrCreatePair(dto.User); + + var groupInfo = shell.GroupFullInfo.GroupPairUserInfos.GetValueOrDefault(dto.User.UID, GroupPairUserInfo.None); + connection.EnsureGroupRelationship(dto.Group.GID, groupInfo == GroupPairUserInfo.None ? null : groupInfo); + connection.UpdatePermissions(dto.SelfToOtherPermissions, dto.OtherToSelfPermissions); + + shell.Users[dto.User.UID] = connection; + return PairOperationResult.Ok(); + } + } + + public PairOperationResult RemoveGroupPair(GroupPairDto dto) + { + lock (_gate) + { + if (_groups.TryGetValue(dto.GID, out var shell)) + { + shell.Users.Remove(dto.User.UID); + } + + PairRegistration? registration = null; + if (_pairs.TryGetValue(dto.User.UID, out var connection)) + { + connection.RemoveGroupRelationship(dto.GID); + registration = TryRemovePairIfNoConnection(connection); + } + + return PairOperationResult.Ok(registration); + } + } + + public PairOperationResult> RemoveGroup(string groupId) + { + lock (_gate) + { + if (!_groups.Remove(groupId, out var shell)) + { + return PairOperationResult>.Fail($"Group {groupId} not found."); + } + + var removed = new List(); + foreach (var connection in shell.Users.Values.ToList()) + { + connection.RemoveGroupRelationship(groupId); + var registration = TryRemovePairIfNoConnection(connection); + if (registration is not null) + { + removed.Add(registration); + } + } + + return PairOperationResult>.Ok(removed); + } + } + + public PairOperationResult AddGroup(GroupFullInfoDto dto) + { + lock (_gate) + { + if (!_groups.TryGetValue(dto.Group.GID, out var shell)) + { + shell = new Syncshell(dto); + _groups[dto.Group.GID] = shell; + } + else + { + shell.Update(dto); + shell.Users.Clear(); + } + + foreach (var (userId, info) in dto.GroupPairUserInfos) + { + if (_pairs.TryGetValue(userId, out var connection)) + { + connection.EnsureGroupRelationship(dto.Group.GID, info == GroupPairUserInfo.None ? null : info); + shell.Users[userId] = connection; + } + } + + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateGroupInfo(GroupInfoDto dto) + { + lock (_gate) + { + if (!_groups.TryGetValue(dto.Group.GID, out var shell)) + { + return PairOperationResult.Fail($"Group {dto.Group.GID} not found."); + } + + var updated = new GroupFullInfoDto( + dto.Group, + dto.Owner, + dto.GroupPermissions, + shell.GroupFullInfo.GroupUserPermissions, + shell.GroupFullInfo.GroupUserInfo, + new Dictionary(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal)); + + shell.Update(updated); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateGroupPairPermissions(GroupPairUserPermissionDto dto) + { + lock (_gate) + { + if (!_groups.TryGetValue(dto.Group.GID, out var shell)) + { + return PairOperationResult.Fail($"Group {dto.Group.GID} not found."); + } + + var updated = shell.GroupFullInfo with { GroupUserPermissions = dto.GroupPairPermissions }; + shell.Update(updated); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateGroupPermissions(GroupPermissionDto dto) + { + lock (_gate) + { + if (!_groups.TryGetValue(dto.Group.GID, out var shell)) + { + return PairOperationResult.Fail($"Group {dto.Group.GID} not found."); + } + + var updated = shell.GroupFullInfo with { GroupPermissions = dto.Permissions }; + shell.Update(updated); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateGroupPairStatus(GroupPairUserInfoDto dto) + { + lock (_gate) + { + if (_pairs.TryGetValue(dto.UID, out var connection)) + { + connection.EnsureGroupRelationship(dto.GID, dto.GroupUserInfo == GroupPairUserInfo.None ? null : dto.GroupUserInfo); + } + + if (_groups.TryGetValue(dto.GID, out var shell)) + { + var infos = new Dictionary(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal) + { + [dto.UID] = dto.GroupUserInfo + }; + var updated = shell.GroupFullInfo with { GroupPairUserInfos = infos }; + shell.Update(updated); + } + + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateGroupStatus(GroupPairUserInfoDto dto) + { + lock (_gate) + { + if (!_groups.TryGetValue(dto.GID, out var shell)) + { + return PairOperationResult.Fail($"Group {dto.GID} not found."); + } + + var updated = shell.GroupFullInfo with { GroupUserInfo = dto.GroupUserInfo }; + shell.Update(updated); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateOtherPermissions(UserPermissionsDto dto) + { + lock (_gate) + { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdatePermissions(connection.SelfToOtherPermissions, dto.Permissions); + return PairOperationResult.Ok(); + } + } + + public PairOperationResult UpdateSelfPermissions(UserPermissionsDto dto) + { + lock (_gate) + { + if (!_pairs.TryGetValue(dto.User.UID, out var connection)) + { + return PairOperationResult.Fail($"Pair {dto.User.UID} not found."); + } + + connection.UpdatePermissions(dto.Permissions, connection.OtherToSelfPermissions); + return PairOperationResult.Ok(); + } + } + + private PairConnection GetOrCreatePair(UserData user) + { + return GetOrCreatePair(user, out _); + } + + private PairConnection GetOrCreatePair(UserData user, out bool created) + { + if (_pairs.TryGetValue(user.UID, out var connection)) + { + created = false; + return connection; + } + + connection = new PairConnection(user); + _pairs[user.UID] = connection; + created = true; + return connection; + } + + private Syncshell GetOrCreateShell(GroupData group) + { + if (_groups.TryGetValue(group.GID, out var shell)) + { + return shell; + } + + var placeholder = new GroupFullInfoDto( + group, + new UserData(string.Empty), + GroupPermissions.NoneSet, + GroupUserPreferredPermissions.NoneSet, + GroupPairUserInfo.None, + new Dictionary(StringComparer.Ordinal)); + + shell = new Syncshell(placeholder); + _groups[group.GID] = shell; + return shell; + } + + private PairRegistration? TryRemovePairIfNoConnection(PairConnection connection) + { + if (connection.HasAnyConnection) + { + return null; + } + + if (connection.IsOnline) + { + connection.SetOffline(); + } + + var userId = connection.User.UID; + _pairs.Remove(userId); + foreach (var shell in _groups.Values) + { + shell.Users.Remove(userId); + } + + return new PairRegistration(new PairUniqueIdentifier(userId), connection.Ident); + } + + public static PairConnection CreateFromFullData(UserFullPairDto dto) + { + var connection = new PairConnection(dto.User); + connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions); + connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus); + + foreach (var groupId in dto.Groups) + { + connection.EnsureGroupRelationship(groupId, null); + } + + return connection; + } + + public static PairConnection CreateFromPartialData(UserPairDto dto) + { + var connection = new PairConnection(dto.User); + connection.UpdatePermissions(dto.OwnPermissions, dto.OtherPermissions); + connection.UpdateStatus(dto.IndividualPairStatus == IndividualPairStatus.None ? null : dto.IndividualPairStatus); + return connection; + } + + public static GroupPairRelationship CreateGroupPairRelationshipFromFullInfo(string userUid, GroupFullInfoDto fullInfo) + { + return new GroupPairRelationship(fullInfo.Group.GID, + fullInfo.GroupPairUserInfos.TryGetValue(userUid, out var info) && info != GroupPairUserInfo.None + ? info + : null); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairState.cs b/LightlessSync/PlayerData/Pairs/PairState.cs new file mode 100644 index 0000000..0e2a508 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairState.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.Group; + +namespace LightlessSync.PlayerData.Pairs; + +public readonly struct PairOperationResult +{ + private PairOperationResult(bool success, string? error) + { + Success = success; + Error = error; + } + + public bool Success { get; } + public string? Error { get; } + + public static PairOperationResult Ok() => new(true, null); + + public static PairOperationResult Fail(string error) => new(false, error); +} + +public readonly struct PairOperationResult +{ + private PairOperationResult(bool success, T value, string? error) + { + Success = success; + Value = value; + Error = error; + } + + public bool Success { get; } + public T Value { get; } + public string? Error { get; } + + public static PairOperationResult Ok(T value) => new(true, value, null); + + public static PairOperationResult Fail(string error) => new(false, default!, error); +} + +public sealed record PairRegistration(PairUniqueIdentifier PairIdent, string? CharacterIdent); + +public sealed class GroupPairRelationship +{ + public GroupPairRelationship(string groupId, GroupPairUserInfo? info) + { + GroupId = groupId; + UserInfo = info; + } + + public string GroupId { get; } + public GroupPairUserInfo? UserInfo { get; private set; } + + public void SetUserInfo(GroupPairUserInfo? info) + { + UserInfo = info; + } +} + +public sealed class PairConnection +{ + public PairConnection(UserData user) + { + User = user; + Groups = new Dictionary(StringComparer.Ordinal); + } + + public UserData User { get; } + public bool IsOnline { get; private set; } + public string? Ident { get; private set; } + public UserPermissions SelfToOtherPermissions { get; private set; } = UserPermissions.NoneSet; + public UserPermissions OtherToSelfPermissions { get; private set; } = UserPermissions.NoneSet; + public IndividualPairStatus? IndividualPairStatus { get; private set; } + public Dictionary Groups { get; } + + public bool IsPaused => SelfToOtherPermissions.IsPaused(); + public bool IsDirectlyPaired => IndividualPairStatus is not null && IndividualPairStatus != API.Data.Enum.IndividualPairStatus.None; + public bool IsOneSided => IndividualPairStatus == API.Data.Enum.IndividualPairStatus.OneSided; + public bool HasAnyConnection => IsDirectlyPaired || Groups.Count > 0; + + public void SetOnline(string? ident) + { + IsOnline = true; + Ident = ident; + } + + public void SetOffline() + { + IsOnline = false; + } + + public void UpdatePermissions(UserPermissions own, UserPermissions other) + { + SelfToOtherPermissions = own; + OtherToSelfPermissions = other; + } + + public void UpdateStatus(IndividualPairStatus? status) + { + IndividualPairStatus = status; + } + + public void EnsureGroupRelationship(string groupId, GroupPairUserInfo? info) + { + if (Groups.TryGetValue(groupId, out var relationship)) + { + relationship.SetUserInfo(info); + } + else + { + Groups[groupId] = new GroupPairRelationship(groupId, info); + } + } + + public void RemoveGroupRelationship(string groupId) + { + Groups.Remove(groupId); + } +} + +public sealed class Syncshell +{ + public Syncshell(GroupFullInfoDto dto) + { + GroupFullInfo = dto; + Users = new Dictionary(StringComparer.Ordinal); + } + + public GroupFullInfoDto GroupFullInfo { get; private set; } + public Dictionary Users { get; } + + public void Update(GroupFullInfoDto dto) + { + GroupFullInfo = dto; + } +} + +public sealed class PairState +{ + public CharacterData? CharacterData { get; set; } + public Guid? TemporaryCollectionId { get; set; } + + public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty); +} + +public readonly record struct PairUniqueIdentifier(string UserId); diff --git a/LightlessSync/PlayerData/Pairs/PairStateCache.cs b/LightlessSync/PlayerData/Pairs/PairStateCache.cs new file mode 100644 index 0000000..67e8c8c --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairStateCache.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using LightlessSync.API.Data; +using LightlessSync.Utils; + +namespace LightlessSync.PlayerData.Pairs; + +public sealed class PairStateCache +{ + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + public void Store(string ident, CharacterData data) + { + if (string.IsNullOrEmpty(ident) || data is null) + { + return; + } + + var state = _cache.GetOrAdd(ident, _ => new PairState()); + state.CharacterData = data.DeepClone(); + } + + public CharacterData? TryLoad(string ident) + { + if (string.IsNullOrEmpty(ident)) + { + return null; + } + + if (_cache.TryGetValue(ident, out var state) && state.CharacterData is not null) + { + return state.CharacterData.DeepClone(); + } + + return null; + } + + public Guid? TryGetTemporaryCollection(string ident) + { + if (string.IsNullOrEmpty(ident)) + { + return null; + } + + if (_cache.TryGetValue(ident, out var state)) + { + return state.TemporaryCollectionId; + } + + return null; + } + + public Guid? StoreTemporaryCollection(string ident, Guid collection) + { + if (string.IsNullOrEmpty(ident) || collection == Guid.Empty) + { + return null; + } + + var state = _cache.GetOrAdd(ident, _ => new PairState()); + state.TemporaryCollectionId = collection; + return collection; + } + + public Guid? ClearTemporaryCollection(string ident) + { + if (string.IsNullOrEmpty(ident)) + { + return null; + } + + if (_cache.TryGetValue(ident, out var state)) + { + var existing = state.TemporaryCollectionId; + state.TemporaryCollectionId = null; + TryRemoveIfEmpty(ident, state); + return existing; + } + + return null; + } + + public IReadOnlyList ClearAllTemporaryCollections() + { + var removed = new List(); + foreach (var (ident, state) in _cache) + { + if (state.TemporaryCollectionId is { } guid && guid != Guid.Empty) + { + removed.Add(guid); + state.TemporaryCollectionId = null; + } + + TryRemoveIfEmpty(ident, state); + } + + return removed; + } + + public void Clear(string ident) + { + if (string.IsNullOrEmpty(ident)) + { + return; + } + + _cache.TryRemove(ident, out _); + } + + private void TryRemoveIfEmpty(string ident, PairState state) + { + if (state.IsEmpty) + { + _cache.TryRemove(ident, out _); + } + } +} diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index 6ead2ca..da53332 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -1,10 +1,17 @@ +using System; using LightlessSync.API.Data; -using LightlessSync.Services; -using LightlessSync.Services.Mediator; +using LightlessSync.API.Data.Comparer; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Utils; +using LightlessSync.Services.Mediator; +using LightlessSync.Services; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace LightlessSync.PlayerData.Pairs; @@ -13,22 +20,24 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtil; private readonly FileUploadManager _fileTransferManager; - private readonly PairManager _pairManager; + private readonly PairLedger _pairLedger; private CharacterData? _lastCreatedData; private CharacterData? _uploadingCharacterData = null; private readonly List _previouslyVisiblePlayers = []; private Task? _fileUploadTask = null; - private readonly HashSet _usersToPushDataTo = []; + private readonly HashSet _usersToPushDataTo = new(UserDataComparer.Instance); private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1); private readonly CancellationTokenSource _runtimeCts = new(); + private readonly Dictionary _lastPushedHashes = new(StringComparer.Ordinal); + private readonly object _pushSync = new(); public VisibleUserDataDistributor(ILogger logger, ApiController apiController, DalamudUtilService dalamudUtil, - PairManager pairManager, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator) + PairLedger pairLedger, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator) { _apiController = apiController; _dalamudUtil = dalamudUtil; - _pairManager = pairManager; + _pairLedger = pairLedger; _fileTransferManager = fileTransferManager; Mediator.Subscribe(this, (_) => FrameworkOnUpdate()); Mediator.Subscribe(this, (msg) => @@ -47,7 +56,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase }); Mediator.Subscribe(this, (_) => PushToAllVisibleUsers()); - Mediator.Subscribe(this, (_) => _previouslyVisiblePlayers.Clear()); + Mediator.Subscribe(this, (_) => HandleDisconnected()); } protected override void Dispose(bool disposing) @@ -63,15 +72,18 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private void PushToAllVisibleUsers(bool forced = false) { - foreach (var user in _pairManager.GetVisibleUsers()) + lock (_pushSync) { - _usersToPushDataTo.Add(user); - } + foreach (var user in GetVisibleUsers()) + { + _usersToPushDataTo.Add(user); + } - if (_usersToPushDataTo.Count > 0) - { - Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count); - PushCharacterData(forced); + if (_usersToPushDataTo.Count > 0) + { + Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count); + PushCharacterData_internalLocked(forced); + } } } @@ -79,8 +91,10 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase { if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return; - var allVisibleUsers = _pairManager.GetVisibleUsers(); - var newVisibleUsers = allVisibleUsers.Except(_previouslyVisiblePlayers).ToList(); + var allVisibleUsers = GetVisibleUsers(); + var newVisibleUsers = allVisibleUsers + .Except(_previouslyVisiblePlayers, UserDataComparer.Instance) + .ToList(); _previouslyVisiblePlayers.Clear(); _previouslyVisiblePlayers.AddRange(allVisibleUsers); if (newVisibleUsers.Count == 0) return; @@ -88,56 +102,144 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase Logger.LogDebug("Scheduling character data push of {data} to {users}", _lastCreatedData?.DataHash.Value ?? string.Empty, string.Join(", ", newVisibleUsers.Select(k => k.AliasOrUID))); - foreach (var user in newVisibleUsers) + lock (_pushSync) { - _usersToPushDataTo.Add(user); + foreach (var user in newVisibleUsers) + { + _usersToPushDataTo.Add(user); + } + PushCharacterData_internalLocked(); } - PushCharacterData(); } private void PushCharacterData(bool forced = false) + { + lock (_pushSync) + { + PushCharacterData_internalLocked(forced); + } + } + + private void PushCharacterData_internalLocked(bool forced = false) { if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; + if (!_apiController.IsConnected || !_fileTransferManager.IsReady) + { + Logger.LogTrace("Skipping character push. Connected: {connected}, UploadManagerReady: {ready}", + _apiController.IsConnected, _fileTransferManager.IsReady); + return; + } _ = Task.Run(async () => { try { - forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash; + Task? uploadTask; + bool forcedPush; + lock (_pushSync) + { + if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; + forcedPush = forced | (_uploadingCharacterData?.DataHash != _lastCreatedData.DataHash); - if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced) - { - _uploadingCharacterData = _lastCreatedData.DeepClone(); - Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", - _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forced); - _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); - } + if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forcedPush) + { + _uploadingCharacterData = _lastCreatedData.DeepClone(); + Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", + _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forcedPush); + _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); + } - if (_fileUploadTask != null) - { - var dataToSend = await _fileUploadTask.ConfigureAwait(false); + uploadTask = _fileUploadTask; + } + + var dataToSend = await uploadTask.ConfigureAwait(false); + var dataHash = dataToSend.DataHash.Value; await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); try { - if (_usersToPushDataTo.Count == 0) return; - Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID))); - await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false); - _usersToPushDataTo.Clear(); + List recipients; + bool shouldSkip = false; + lock (_pushSync) + { + if (_usersToPushDataTo.Count == 0) return; + recipients = forcedPush + ? _usersToPushDataTo.ToList() + : _usersToPushDataTo + .Where(user => !_lastPushedHashes.TryGetValue(user.UID, out var sentHash) || !string.Equals(sentHash, dataHash, StringComparison.Ordinal)) + .ToList(); + + if (recipients.Count == 0 && !forcedPush) + { + Logger.LogTrace("All recipients already have character data hash {hash}, skipping push.", dataHash); + _usersToPushDataTo.Clear(); + shouldSkip = true; + } + } + + if (shouldSkip) + return; + + Logger.LogDebug("Pushing {data} to {users}", dataHash, string.Join(", ", recipients.Select(k => k.AliasOrUID))); + await _apiController.PushCharacterData(dataToSend, recipients).ConfigureAwait(false); + + lock (_pushSync) + { + foreach (var user in recipients) + { + _lastPushedHashes[user.UID] = dataHash; + _usersToPushDataTo.Remove(user); + } + + if (!forcedPush && _usersToPushDataTo.Count > 0) + { + foreach (var satisfied in _usersToPushDataTo + .Where(user => _lastPushedHashes.TryGetValue(user.UID, out var sentHash) && string.Equals(sentHash, dataHash, StringComparison.Ordinal)) + .ToList()) + { + _usersToPushDataTo.Remove(satisfied); + } + } + + if (forcedPush) + { + _usersToPushDataTo.Clear(); + } + } } finally { _pushDataSemaphore.Release(); } } - } - catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested) - { - Logger.LogDebug("PushCharacterData cancelled"); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to push character data"); - } + catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested) + { + Logger.LogDebug("PushCharacterData cancelled"); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to push character data"); + } }); } -} \ No newline at end of file + + private void HandleDisconnected() + { + _fileTransferManager.CancelUpload(); + _previouslyVisiblePlayers.Clear(); + + lock (_pushSync) + { + _usersToPushDataTo.Clear(); + _lastPushedHashes.Clear(); + _uploadingCharacterData = null; + _fileUploadTask = null; + } + } + + private List GetVisibleUsers() + { + return _pairLedger.GetVisiblePairs() + .Select(connection => connection.User) + .ToList(); + } +} diff --git a/LightlessSync/PlayerData/Services/CacheCreationService.cs b/LightlessSync/PlayerData/Services/CacheCreationService.cs index 9c64d8f..69a975e 100644 --- a/LightlessSync/PlayerData/Services/CacheCreationService.cs +++ b/LightlessSync/PlayerData/Services/CacheCreationService.cs @@ -20,6 +20,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase private readonly CancellationTokenSource _runtimeCts = new(); private CancellationTokenSource _creationCts = new(); private CancellationTokenSource _debounceCts = new(); + private string? _lastPublishedHash; private bool _haltCharaDataCreation; private bool _isZoning = false; @@ -183,7 +184,18 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase { if (_isZoning || _haltCharaDataCreation) return; - if (_cachesToCreate.Count == 0) return; + bool hasCaches; + _cacheCreateLock.Wait(); + try + { + hasCaches = _cachesToCreate.Count > 0; + } + finally + { + _cacheCreateLock.Release(); + } + + if (!hasCaches) return; if (_playerRelatedObjects.Any(p => p.Value.CurrentDrawCondition is not (GameObjectHandler.DrawCondition.None or GameObjectHandler.DrawCondition.DrawObjectZero or GameObjectHandler.DrawCondition.ObjectZero))) @@ -197,6 +209,11 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase _creationCts = new(); _cacheCreateLock.Wait(_creationCts.Token); var objectKindsToCreate = _cachesToCreate.ToList(); + if (objectKindsToCreate.Count == 0) + { + _cacheCreateLock.Release(); + return; + } foreach (var creationObj in objectKindsToCreate) { _currentlyCreating.Add(creationObj); @@ -225,8 +242,17 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase _playerData.SetFragment(kvp.Key, kvp.Value); } - Mediator.Publish(new CharacterDataCreatedMessage(_playerData.ToAPI())); - _currentlyCreating.Clear(); + var apiData = _playerData.ToAPI(); + var currentHash = apiData.DataHash.Value; + if (string.Equals(_lastPublishedHash, currentHash, StringComparison.Ordinal)) + { + Logger.LogTrace("Cache creation produced identical character data ({hash}), skipping publish.", currentHash); + } + else + { + _lastPublishedHash = currentHash; + Mediator.Publish(new CharacterDataCreatedMessage(apiData)); + } } catch (OperationCanceledException) { @@ -238,6 +264,7 @@ public sealed class CacheCreationService : DisposableMediatorSubscriberBase } finally { + _currentlyCreating.Clear(); Logger.LogDebug("Cache Creation complete"); } }); diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 01a4de4..542d9a1 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -13,14 +13,19 @@ using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Services; using LightlessSync.Services; +using LightlessSync.Services.Chat; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.CharaData; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Services.TextureCompression; using LightlessSync.UI; using LightlessSync.UI.Components; using LightlessSync.UI.Components.Popup; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Tags; +using LightlessSync.UI.Services; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.SignalR; @@ -28,8 +33,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NReco.Logging.File; +using System; +using System.IO; using System.Net.Http.Headers; using System.Reflection; +using OtterTex; namespace LightlessSync; @@ -43,6 +51,7 @@ public sealed class Plugin : IDalamudPlugin ITextureProvider textureProvider, IContextMenu contextMenu, IGameInteropProvider gameInteropProvider, IGameConfig gameConfig, ISigScanner sigScanner, INamePlateGui namePlateGui, IAddonLifecycle addonLifecycle) { + NativeDll.Initialize(pluginInterface.AssemblyLocation.DirectoryName); if (!Directory.Exists(pluginInterface.ConfigDirectory.FullName)) Directory.CreateDirectory(pluginInterface.ConfigDirectory.FullName); var traceDir = Path.Join(pluginInterface.ConfigDirectory.FullName, "tracelog"); @@ -96,6 +105,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -103,11 +113,22 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(s => + { + var logger = s.GetRequiredService>(); + return new TextureMetadataHelper(logger, gameData); + }); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); + collection.AddSingleton(s => new PairFactory( + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + new Lazy(() => s.GetRequiredService()), + s.GetRequiredService>())); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -116,9 +137,15 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton(); collection.AddSingleton(s => new Lazy(() => s.GetRequiredService())); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); - collection.AddSingleton(); + collection.AddSingleton(s => new TransientResourceManager(s.GetRequiredService>(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton(); collection.AddSingleton(); @@ -141,30 +168,53 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService>(), s.GetRequiredService())); collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService>(), clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig, - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService(), new Lazy(() => s.GetRequiredService()))); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(); + collection.AddSingleton(s => new PairHandlerRegistry( + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService>())); + collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton((s) => new DtrEntry( s.GetRequiredService>(), dtrBar, s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton(s => new PairManager(s.GetRequiredService>(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), contextMenu, s.GetRequiredService())); + collection.AddSingleton(s => new PairCoordinator( + s.GetRequiredService>(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(addonLifecycle); - collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData, - p.GetRequiredService>(), p.GetRequiredService(), p.GetRequiredService(), objectTable, - p.GetRequiredService(), p.GetRequiredService(), p.GetRequiredService(), clientState)); + collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData, p.GetRequiredService>(), p.GetRequiredService(), p.GetRequiredService(), objectTable, + p.GetRequiredService(), + p.GetRequiredService(), + p.GetRequiredService(), + clientState, + p.GetRequiredService(), + p.GetRequiredService(), + p.GetRequiredService(), + p.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService>(), pluginInterface, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService>(), pluginInterface, @@ -190,7 +240,9 @@ public sealed class Plugin : IDalamudPlugin notificationManager, chatGui, s.GetRequiredService(), - s.GetRequiredService())); + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton((s) => { var httpClient = new HttpClient(); @@ -199,6 +251,7 @@ public sealed class Plugin : IDalamudPlugin return httpClient; }); collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName)); + collection.AddSingleton((s) => new ChatConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => { var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName); @@ -216,6 +269,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); + collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton>(s => s.GetRequiredService()); @@ -226,8 +280,15 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton>(s => s.GetRequiredService()); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(sp => new ActorObjectService( + sp.GetRequiredService>(), + framework, + gameInteropProvider, + objectTable, + clientState, + sp.GetRequiredService())); collection.AddSingleton(); - collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService>(), clientState, objectTable, framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); // add scoped services @@ -247,13 +308,14 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped(); collection.AddScoped(); collection.AddScoped(); + collection.AddScoped(); collection.AddScoped((s) => new EditProfileUi(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); collection.AddScoped((s) => new BroadcastUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); collection.AddScoped((s) => new LightlessNotificationUi( @@ -268,8 +330,9 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped((s) => new UiService(s.GetRequiredService>(), pluginInterface.UiBuilder, s.GetRequiredService(), s.GetRequiredService(), s.GetServices(), s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); + s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService())); collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); @@ -278,12 +341,14 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), namePlateGui, clientState, - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), clientState, s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), clientState, s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs new file mode 100644 index 0000000..2305c2a --- /dev/null +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -0,0 +1,754 @@ +using LightlessSync; +using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Dalamud.Game; +using Dalamud.Game.ClientState; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Hooking; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; +using FFXIVClientStructs.Interop; +using System.Threading; + +namespace LightlessSync.Services.ActorTracking; + +public sealed unsafe class ActorObjectService : IHostedService, IDisposable +{ + public readonly record struct ActorDescriptor( + string Name, + string HashedContentId, + nint Address, + ushort ObjectIndex, + bool IsLocalPlayer, + bool IsInGpose, + DalamudObjectKind ObjectKind, + LightlessObjectKind? OwnedKind, + uint OwnerEntityId); + + private readonly ILogger _logger; + private readonly IFramework _framework; + private readonly IGameInteropProvider _interop; + private readonly IObjectTable _objectTable; + private readonly IClientState _clientState; + private readonly LightlessMediator _mediator; + + private readonly ConcurrentDictionary _activePlayers = new(); + private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); + private ActorDescriptor[] _playerCharacterSnapshot = Array.Empty(); + private nint[] _playerAddressSnapshot = Array.Empty(); + private readonly HashSet _renderedPlayers = new(); + private readonly HashSet _renderedCompanions = new(); + private readonly Dictionary _ownedObjects = new(); + private nint[] _renderedPlayerSnapshot = Array.Empty(); + private nint[] _renderedCompanionSnapshot = Array.Empty(); + private nint[] _ownedObjectSnapshot = Array.Empty(); + private IReadOnlyDictionary _ownedObjectMapSnapshot = new Dictionary(); + private nint _localPlayerAddress = nint.Zero; + private nint _localPetAddress = nint.Zero; + private nint _localMinionMountAddress = nint.Zero; + private nint _localCompanionAddress = nint.Zero; + + private Hook? _onInitializeHook; + private Hook? _onTerminateHook; + private Hook? _onDestructorHook; + private Hook? _onCompanionInitializeHook; + private Hook? _onCompanionTerminateHook; + + private bool _hooksActive; + private static readonly TimeSpan SnapshotRefreshInterval = TimeSpan.FromSeconds(1); + private DateTime _nextRefreshAllowed = DateTime.MinValue; + + public ActorObjectService( + ILogger logger, + IFramework framework, + IGameInteropProvider interop, + IObjectTable objectTable, + IClientState clientState, + LightlessMediator mediator) + { + _logger = logger; + _framework = framework; + _interop = interop; + _objectTable = objectTable; + _clientState = clientState; + _mediator = mediator; + } + + public IReadOnlyList PlayerAddresses => Volatile.Read(ref _playerAddressSnapshot); + + public IEnumerable PlayerDescriptors => _activePlayers.Values; + public IReadOnlyList PlayerCharacterDescriptors => Volatile.Read(ref _playerCharacterSnapshot); + + public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); + public bool TryGetPlayerByName(string name, out ActorDescriptor descriptor) + { + descriptor = default; + + if (!_actorsByName.TryGetValue(name, out var entries) || entries.IsEmpty) + return false; + + ActorDescriptor? best = null; + foreach (var candidate in entries.Values) + { + if (best is null || IsBetterNameMatch(candidate, best.Value)) + { + best = candidate; + } + } + + if (best is { } selected) + { + descriptor = selected; + return true; + } + + return false; + } + public bool HooksActive => _hooksActive; + public IReadOnlyList RenderedPlayerAddresses => Volatile.Read(ref _renderedPlayerSnapshot); + public IReadOnlyList RenderedCompanionAddresses => Volatile.Read(ref _renderedCompanionSnapshot); + public IReadOnlyList OwnedObjectAddresses => Volatile.Read(ref _ownedObjectSnapshot); + public IReadOnlyDictionary OwnedObjects => Volatile.Read(ref _ownedObjectMapSnapshot); + public nint LocalPlayerAddress => Volatile.Read(ref _localPlayerAddress); + public nint LocalPetAddress => Volatile.Read(ref _localPetAddress); + public nint LocalMinionOrMountAddress => Volatile.Read(ref _localMinionMountAddress); + public nint LocalCompanionAddress => Volatile.Read(ref _localCompanionAddress); + + public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address) + { + address = kind switch + { + LightlessObjectKind.Player => Volatile.Read(ref _localPlayerAddress), + LightlessObjectKind.Pet => Volatile.Read(ref _localPetAddress), + LightlessObjectKind.MinionOrMount => Volatile.Read(ref _localMinionMountAddress), + LightlessObjectKind.Companion => Volatile.Read(ref _localCompanionAddress), + _ => nint.Zero + }; + + return address != nint.Zero; + } + + public bool TryGetOwnedActor(uint ownerEntityId, LightlessObjectKind? kindFilter, out ActorDescriptor descriptor) + { + descriptor = default; + foreach (var candidate in _activePlayers.Values) + { + if (candidate.OwnerEntityId != ownerEntityId) + continue; + + if (kindFilter.HasValue && candidate.OwnedKind != kindFilter) + continue; + + descriptor = candidate; + return true; + } + + return false; + } + + public bool TryGetPlayerAddressByHash(string hash, out nint address) + { + if (TryGetActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero) + { + address = descriptor.Address; + return true; + } + + address = nint.Zero; + return false; + } + + public void RefreshTrackedActors(bool force = false) + { + var now = DateTime.UtcNow; + if (!force && _hooksActive) + { + if (now < _nextRefreshAllowed) + return; + + _nextRefreshAllowed = now + SnapshotRefreshInterval; + } + + if (_framework.IsInFrameworkUpdateThread) + { + RefreshTrackedActorsInternal(); + } + else + { + _framework.RunOnFrameworkThread(RefreshTrackedActorsInternal); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + try + { + InitializeHooks(); + var warmupTask = WarmupExistingActors(); + return warmupTask; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize ActorObjectService hooks, falling back to empty cache."); + DisposeHooks(); + return Task.CompletedTask; + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + DisposeHooks(); + _activePlayers.Clear(); + _actorsByHash.Clear(); + _actorsByName.Clear(); + Volatile.Write(ref _playerCharacterSnapshot, Array.Empty()); + Volatile.Write(ref _playerAddressSnapshot, Array.Empty()); + Volatile.Write(ref _renderedPlayerSnapshot, Array.Empty()); + Volatile.Write(ref _renderedCompanionSnapshot, Array.Empty()); + Volatile.Write(ref _ownedObjectSnapshot, Array.Empty()); + Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary()); + Volatile.Write(ref _localPlayerAddress, nint.Zero); + Volatile.Write(ref _localPetAddress, nint.Zero); + Volatile.Write(ref _localMinionMountAddress, nint.Zero); + Volatile.Write(ref _localCompanionAddress, nint.Zero); + _renderedPlayers.Clear(); + _renderedCompanions.Clear(); + _ownedObjects.Clear(); + return Task.CompletedTask; + } + + private void InitializeHooks() + { + if (_hooksActive) + return; + + _onInitializeHook = _interop.HookFromAddress( + (nint)Character.StaticVirtualTablePointer->OnInitialize, + OnCharacterInitialized); + + _onTerminateHook = _interop.HookFromAddress( + (nint)Character.StaticVirtualTablePointer->Terminate, + OnCharacterTerminated); + + _onDestructorHook = _interop.HookFromAddress( + (nint)Character.StaticVirtualTablePointer->Dtor, + OnCharacterDisposed); + + _onCompanionInitializeHook = _interop.HookFromAddress( + (nint)Companion.StaticVirtualTablePointer->OnInitialize, + OnCompanionInitialized); + + _onCompanionTerminateHook = _interop.HookFromAddress( + (nint)Companion.StaticVirtualTablePointer->Terminate, + OnCompanionTerminated); + + _onInitializeHook.Enable(); + _onTerminateHook.Enable(); + _onDestructorHook.Enable(); + _onCompanionInitializeHook.Enable(); + _onCompanionTerminateHook.Enable(); + + _hooksActive = true; + _logger.LogDebug("ActorObjectService hooks enabled."); + } + + private Task WarmupExistingActors() + { + return _framework.RunOnFrameworkThread(() => + { + RefreshTrackedActorsInternal(); + _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; + }); + } + + private void OnCharacterInitialized(Character* chara) + { + try + { + _onInitializeHook!.Original(chara); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error invoking original character initialize."); + } + + QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara)); + } + + private 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."); + } + } + + private GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) + { + var address = (nint)chara; + QueueFrameworkUpdate(() => UntrackGameObject(address)); + try + { + return _onDestructorHook!.Original(chara, freeMemory); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error invoking original character destructor."); + return null; + } + } + + private void TrackGameObject(GameObject* gameObject) + { + if (gameObject == null) + return; + + var objectKind = (DalamudObjectKind)gameObject->ObjectKind; + + if (!IsSupportedObjectKind(objectKind)) + return; + + if (BuildDescriptor(gameObject, objectKind) is not { } descriptor) + return; + + if (descriptor.ObjectKind != DalamudObjectKind.Player && descriptor.OwnedKind is null) + return; + + if (_activePlayers.TryGetValue(descriptor.Address, out var existing)) + { + RemoveDescriptorFromIndexes(existing); + RemoveDescriptorFromCollections(existing); + } + + _activePlayers[descriptor.Address] = descriptor; + IndexDescriptor(descriptor); + AddDescriptorToCollections(descriptor); + RebuildSnapshots(); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Actor tracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind} local={Local} gpose={Gpose}", + descriptor.Name, + descriptor.Address, + descriptor.ObjectIndex, + descriptor.OwnedKind?.ToString() ?? "", + descriptor.IsLocalPlayer, + descriptor.IsInGpose); + } + + _mediator.Publish(new ActorTrackedMessage(descriptor)); + } + + private ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind) + { + if (gameObject == null) + return null; + + var address = (nint)gameObject; + string name = string.Empty; + ushort objectIndex = (ushort)gameObject->ObjectIndex; + bool isInGpose = objectIndex >= 200; + bool isLocal = _clientState.LocalPlayer?.Address == address; + string hashedCid = string.Empty; + + if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter) + { + name = playerCharacter.Name.TextValue ?? string.Empty; + objectIndex = playerCharacter.ObjectIndex; + isInGpose = objectIndex >= 200; + isLocal = playerCharacter.Address == _clientState.LocalPlayer?.Address; + } + else + { + name = gameObject->NameString ?? string.Empty; + } + + if (objectKind == DalamudObjectKind.Player) + { + hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); + } + + var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal); + + return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); + } + + private (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer) + { + if (gameObject == null) + return (null, 0); + + if (objectKind == DalamudObjectKind.Player) + { + var entityId = ((Character*)gameObject)->EntityId; + return (isLocalPlayer ? LightlessObjectKind.Player : null, entityId); + } + + if (isLocalPlayer) + { + var entityId = ((Character*)gameObject)->EntityId; + return (LightlessObjectKind.Player, entityId); + } + + if (_clientState.LocalPlayer is not { } localPlayer) + return (null, 0); + + var ownerId = gameObject->OwnerId; + if (ownerId == 0) + { + var character = (Character*)gameObject; + if (character != null) + { + ownerId = character->CompanionOwnerId; + if (ownerId == 0) + { + var parent = character->GetParentCharacter(); + if (parent != null) + { + ownerId = parent->EntityId; + } + } + } + } + + if (ownerId == 0 || ownerId != localPlayer.EntityId) + return (null, ownerId); + + var ownedKind = objectKind switch + { + DalamudObjectKind.MountType => LightlessObjectKind.MinionOrMount, + DalamudObjectKind.Companion => LightlessObjectKind.MinionOrMount, + DalamudObjectKind.BattleNpc => gameObject->BattleNpcSubKind switch + { + BattleNpcSubKind.Buddy => LightlessObjectKind.Companion, + BattleNpcSubKind.Pet => LightlessObjectKind.Pet, + _ => (LightlessObjectKind?)null, + }, + _ => (LightlessObjectKind?)null, + }; + + return (ownedKind, ownerId); + } + + private void UntrackGameObject(nint address) + { + if (address == nint.Zero) + return; + + if (_activePlayers.TryRemove(address, out var descriptor)) + { + RemoveDescriptorFromIndexes(descriptor); + RemoveDescriptorFromCollections(descriptor); + RebuildSnapshots(); + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", + descriptor.Name, + descriptor.Address, + descriptor.ObjectIndex, + descriptor.OwnedKind?.ToString() ?? ""); + } + + _mediator.Publish(new ActorUntrackedMessage(descriptor)); + } + } + + private void RefreshTrackedActorsInternal() + { + var addresses = EnumerateActiveCharacterAddresses(); + HashSet seen = new(addresses.Count); + + foreach (var address in addresses) + { + if (address == nint.Zero) + continue; + + if (!seen.Add(address)) + continue; + + if (_activePlayers.ContainsKey(address)) + continue; + + TrackGameObject((GameObject*)address); + } + + var stale = _activePlayers.Keys.Where(addr => !seen.Contains(addr)).ToList(); + foreach (var staleAddress in stale) + { + UntrackGameObject(staleAddress); + } + + if (_hooksActive) + { + _nextRefreshAllowed = DateTime.UtcNow + SnapshotRefreshInterval; + } + } + + private void IndexDescriptor(ActorDescriptor descriptor) + { + if (!string.IsNullOrEmpty(descriptor.HashedContentId)) + { + _actorsByHash[descriptor.HashedContentId] = descriptor; + } + + if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name)) + { + var bucket = _actorsByName.GetOrAdd(descriptor.Name, _ => new ConcurrentDictionary()); + bucket[descriptor.Address] = descriptor; + } + } + + private static bool IsBetterNameMatch(ActorDescriptor candidate, ActorDescriptor current) + { + if (!candidate.IsInGpose && current.IsInGpose) + return true; + if (candidate.IsInGpose && !current.IsInGpose) + return false; + + return candidate.ObjectIndex < current.ObjectIndex; + } + + private void OnCompanionInitialized(Companion* companion) + { + try + { + _onCompanionInitializeHook!.Original(companion); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error invoking original companion initialize."); + } + + QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion)); + } + + private 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."); + } + } + + private void RemoveDescriptorFromIndexes(ActorDescriptor descriptor) + { + if (!string.IsNullOrEmpty(descriptor.HashedContentId)) + { + _actorsByHash.TryRemove(descriptor.HashedContentId, out _); + } + + if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name)) + { + if (_actorsByName.TryGetValue(descriptor.Name, out var bucket)) + { + bucket.TryRemove(descriptor.Address, out _); + if (bucket.IsEmpty) + { + _actorsByName.TryRemove(descriptor.Name, out _); + } + } + } + } + + private void AddDescriptorToCollections(ActorDescriptor descriptor) + { + if (descriptor.ObjectKind == DalamudObjectKind.Player) + { + _renderedPlayers.Add(descriptor.Address); + if (descriptor.IsLocalPlayer) + { + Volatile.Write(ref _localPlayerAddress, descriptor.Address); + } + } + else if (descriptor.ObjectKind == DalamudObjectKind.Companion) + { + _renderedCompanions.Add(descriptor.Address); + } + + if (descriptor.OwnedKind is { } ownedKind) + { + _ownedObjects[descriptor.Address] = ownedKind; + switch (ownedKind) + { + case LightlessObjectKind.Player: + Volatile.Write(ref _localPlayerAddress, descriptor.Address); + break; + case LightlessObjectKind.Pet: + Volatile.Write(ref _localPetAddress, descriptor.Address); + break; + case LightlessObjectKind.MinionOrMount: + Volatile.Write(ref _localMinionMountAddress, descriptor.Address); + break; + case LightlessObjectKind.Companion: + Volatile.Write(ref _localCompanionAddress, descriptor.Address); + break; + } + } + } + + private void RemoveDescriptorFromCollections(ActorDescriptor descriptor) + { + if (descriptor.ObjectKind == DalamudObjectKind.Player) + { + _renderedPlayers.Remove(descriptor.Address); + if (descriptor.IsLocalPlayer && Volatile.Read(ref _localPlayerAddress) == descriptor.Address) + { + Volatile.Write(ref _localPlayerAddress, nint.Zero); + } + } + else if (descriptor.ObjectKind == DalamudObjectKind.Companion) + { + _renderedCompanions.Remove(descriptor.Address); + if (Volatile.Read(ref _localCompanionAddress) == descriptor.Address) + { + Volatile.Write(ref _localCompanionAddress, nint.Zero); + } + } + + if (descriptor.OwnedKind is { } ownedKind) + { + _ownedObjects.Remove(descriptor.Address); + switch (ownedKind) + { + case LightlessObjectKind.Player when Volatile.Read(ref _localPlayerAddress) == descriptor.Address: + Volatile.Write(ref _localPlayerAddress, nint.Zero); + break; + case LightlessObjectKind.Pet when Volatile.Read(ref _localPetAddress) == descriptor.Address: + Volatile.Write(ref _localPetAddress, nint.Zero); + break; + case LightlessObjectKind.MinionOrMount when Volatile.Read(ref _localMinionMountAddress) == descriptor.Address: + Volatile.Write(ref _localMinionMountAddress, nint.Zero); + break; + case LightlessObjectKind.Companion when Volatile.Read(ref _localCompanionAddress) == descriptor.Address: + Volatile.Write(ref _localCompanionAddress, nint.Zero); + break; + } + } + } + + private void RebuildSnapshots() + { + var playerDescriptors = _activePlayers.Values + .Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) + .ToArray(); + + Volatile.Write(ref _playerCharacterSnapshot, playerDescriptors); + Volatile.Write(ref _playerAddressSnapshot, playerDescriptors.Select(d => d.Address).ToArray()); + Volatile.Write(ref _renderedPlayerSnapshot, _renderedPlayers.ToArray()); + Volatile.Write(ref _renderedCompanionSnapshot, _renderedCompanions.ToArray()); + Volatile.Write(ref _ownedObjectSnapshot, _ownedObjects.Keys.ToArray()); + Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary(_ownedObjects)); + } + + private void QueueFrameworkUpdate(Action action) + { + if (action == null) + return; + + if (_framework.IsInFrameworkUpdateThread) + { + action(); + return; + } + + _framework.RunOnFrameworkThread(action); + } + + private void DisposeHooks() + { + var hadHooks = _hooksActive + || _onInitializeHook is not null + || _onTerminateHook is not null + || _onDestructorHook is not null + || _onCompanionInitializeHook is not null + || _onCompanionTerminateHook is not null; + + _onInitializeHook?.Disable(); + _onTerminateHook?.Disable(); + _onDestructorHook?.Disable(); + _onCompanionInitializeHook?.Disable(); + _onCompanionTerminateHook?.Disable(); + + _onInitializeHook?.Dispose(); + _onTerminateHook?.Dispose(); + _onDestructorHook?.Dispose(); + _onCompanionInitializeHook?.Dispose(); + _onCompanionTerminateHook?.Dispose(); + + _onInitializeHook = null; + _onTerminateHook = null; + _onDestructorHook = null; + _onCompanionInitializeHook = null; + _onCompanionTerminateHook = null; + + _hooksActive = false; + + if (hadHooks) + { + _logger.LogDebug("ActorObjectService hooks disabled."); + } + } + + public void Dispose() + { + DisposeHooks(); + GC.SuppressFinalize(this); + } + + private static bool IsSupportedObjectKind(DalamudObjectKind objectKind) => + objectKind is DalamudObjectKind.Player + or DalamudObjectKind.BattleNpc + or DalamudObjectKind.Companion + or DalamudObjectKind.MountType; + + private static List EnumerateActiveCharacterAddresses() + { + var results = new List(64); + var manager = GameObjectManager.Instance(); + if (manager == null) + return results; + + const int objectLimit = 200; + + unsafe + { + for (var i = 0; i < objectLimit; i++) + { + Pointer objPtr = manager->Objects.IndexSorted[i]; + var obj = objPtr.Value; + if (obj == null) + continue; + + var objectKind = (DalamudObjectKind)obj->ObjectKind; + if (!IsSupportedObjectKind(objectKind)) + continue; + + results.Add((nint)obj); + } + } + + return results; + } +} diff --git a/LightlessSync/Services/BroadcastScanningService.cs b/LightlessSync/Services/BroadcastScanningService.cs index 95abdae..45f0fa1 100644 --- a/LightlessSync/Services/BroadcastScanningService.cs +++ b/LightlessSync/Services/BroadcastScanningService.cs @@ -1,7 +1,7 @@ -using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Plugin.Services; +using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; @@ -11,7 +11,7 @@ namespace LightlessSync.Services; public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable { private readonly ILogger _logger; - private readonly IObjectTable _objectTable; + private readonly ActorObjectService _actorTracker; private readonly IFramework _framework; private readonly BroadcastService _broadcastService; @@ -40,17 +40,14 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); public BroadcastScannerService(ILogger logger, - IClientState clientState, - IObjectTable objectTable, IFramework framework, BroadcastService broadcastService, LightlessMediator mediator, NameplateHandler nameplateHandler, - DalamudUtilService dalamudUtil, - LightlessConfigService configService) : base(logger, mediator) + ActorObjectService actorTracker) : base(logger, mediator) { _logger = logger; - _objectTable = objectTable; + _actorTracker = actorTracker; _broadcastService = broadcastService; _nameplateHandler = nameplateHandler; @@ -76,12 +73,12 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos var now = DateTime.UtcNow; - foreach (var obj in _objectTable) + foreach (var address in _actorTracker.PlayerAddresses) { - if (obj is not IPlayerCharacter player || player.Address == IntPtr.Zero) + if (address == nint.Zero) continue; - var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address); + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now; if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize) @@ -237,6 +234,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos _framework.Update -= OnFrameworkUpdate; _cleanupCts.Cancel(); _cleanupTask?.Wait(100); + _cleanupCts.Dispose(); _nameplateHandler.Uninit(); } } diff --git a/LightlessSync/Services/CharaData/CharaDataManager.cs b/LightlessSync/Services/CharaData/CharaDataManager.cs index 38ec1c7..d8b2387 100644 --- a/LightlessSync/Services/CharaData/CharaDataManager.cs +++ b/LightlessSync/Services/CharaData/CharaDataManager.cs @@ -6,9 +6,9 @@ using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.CharaData.Models; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; @@ -28,7 +28,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase private readonly List _nearbyData = []; private readonly CharaDataNearbyManager _nearbyManager; private readonly CharaDataCharacterHandler _characterHandler; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly Dictionary _ownCharaData = []; private readonly Dictionary _sharedMetaInfoTimeoutTasks = []; private readonly Dictionary> _sharedWithYouData = []; @@ -45,7 +45,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase LightlessMediator lightlessMediator, IpcManager ipcManager, DalamudUtilService dalamudUtilService, FileDownloadManagerFactory fileDownloadManagerFactory, CharaDataConfigService charaDataConfigService, CharaDataNearbyManager charaDataNearbyManager, - CharaDataCharacterHandler charaDataCharacterHandler, PairManager pairManager) : base(logger, lightlessMediator) + CharaDataCharacterHandler charaDataCharacterHandler, PairUiService pairUiService) : base(logger, lightlessMediator) { _apiController = apiController; _fileHandler = charaDataFileHandler; @@ -54,7 +54,7 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase _configService = charaDataConfigService; _nearbyManager = charaDataNearbyManager; _characterHandler = charaDataCharacterHandler; - _pairManager = pairManager; + _pairUiService = pairUiService; lightlessMediator.Subscribe(this, (msg) => { _connectCts?.Cancel(); @@ -421,9 +421,10 @@ public sealed partial class CharaDataManager : DisposableMediatorSubscriberBase }); var result = await GetSharedWithYouTask.ConfigureAwait(false); + var snapshot = _pairUiService.GetSnapshot(); foreach (var grouping in result.GroupBy(r => r.Uploader)) { - var pair = _pairManager.GetPairByUID(grouping.Key.UID); + snapshot.PairsByUid.TryGetValue(grouping.Key.UID, out var pair); if (pair?.IsPaused ?? false) continue; List newList = new(); foreach (var item in grouping) diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 27235f6..75c25d6 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -1,4 +1,4 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.FileCache; using LightlessSync.Services.Mediator; @@ -40,21 +40,16 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public int TotalFiles { get; internal set; } internal Dictionary> LastAnalysis { get; } = []; public CharacterAnalysisSummary LatestSummary => _latestSummary; - public void CancelAnalyze() { _analysisCts?.CancelDispose(); _analysisCts = null; } - public async Task ComputeAnalysis(bool print = true, bool recalculate = false) { Logger.LogDebug("=== Calculating Character Analysis ==="); - _analysisCts = _analysisCts?.CancelRecreate() ?? new(); - var cancelToken = _analysisCts.Token; - var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList(); if (allFiles.Exists(c => !c.IsComputed || recalculate)) { @@ -62,7 +57,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable TotalFiles = remaining.Count; CurrentFile = 1; Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count); - Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer))); try { @@ -72,9 +66,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false); CurrentFile++; } - _fileCacheManager.WriteOutFullCsv(); - } catch (Exception ex) { @@ -87,36 +79,49 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } RecalculateSummary(); - Mediator.Publish(new CharacterDataAnalyzedMessage()); - _analysisCts.CancelDispose(); _analysisCts = null; - if (print) PrintAnalysis(); } - public void Dispose() { _analysisCts.CancelDispose(); } - + public async Task UpdateFileEntriesAsync(IEnumerable filePaths, CancellationToken token) + { + var normalized = new HashSet( + filePaths.Where(path => !string.IsNullOrWhiteSpace(path)), + StringComparer.OrdinalIgnoreCase); + if (normalized.Count == 0) + { + return; + } + foreach (var objectEntries in LastAnalysis.Values) + { + foreach (var entry in objectEntries.Values) + { + if (!entry.FilePaths.Any(path => normalized.Contains(path))) + { + continue; + } + token.ThrowIfCancellationRequested(); + await entry.ComputeSizes(_fileCacheManager, token).ConfigureAwait(false); + } + } + } private async Task BaseAnalysis(CharacterData charaData, CancellationToken token) { if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return; - LastAnalysis.Clear(); - foreach (var obj in charaData.FileReplacements) { Dictionary data = new(StringComparer.OrdinalIgnoreCase); foreach (var fileEntry in obj.Value) { token.ThrowIfCancellationRequested(); - var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList(); if (fileCacheEntries.Count == 0) continue; - var filePath = fileCacheEntries[0].ResolvedFilepath; FileInfo fi = new(filePath); string ext = "unk?"; @@ -128,9 +133,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { Logger.LogWarning(ex, "Could not identify extension for {path}", filePath); } - var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false); - foreach (var entry in fileCacheEntries) { data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, @@ -141,17 +144,13 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable tris); } } - LastAnalysis[obj.Key] = data; } RecalculateSummary(); - Mediator.Publish(new CharacterDataAnalyzedMessage()); - _lastDataHash = charaData.DataHash.Value; } - private void RecalculateSummary() { var builder = ImmutableDictionary.CreateBuilder(); @@ -177,7 +176,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _latestSummary = new CharacterAnalysisSummary(builder.ToImmutable()); } - private void PrintAnalysis() { if (LastAnalysis.Count == 0) return; @@ -186,7 +184,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable int fileCounter = 1; int totalFiles = kvp.Value.Count; Logger.LogInformation("=== Analysis for {obj} ===", kvp.Key); - foreach (var entry in kvp.Value.OrderBy(b => b.Value.GamePaths.OrderBy(p => p, StringComparer.Ordinal).First(), StringComparer.Ordinal)) { Logger.LogInformation("File {x}/{y}: {hash}", fileCounter++, totalFiles, entry.Key); @@ -215,7 +212,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", kvp.Value.Count, UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.OriginalSize)), UiSharedService.ByteToString(kvp.Value.Sum(v => v.Value.CompressedSize))); } - Logger.LogInformation("=== Total summary for all currently present objects ==="); Logger.LogInformation("Total files: {count}, size extracted: {size}, size compressed: {sizeComp}", LastAnalysis.Values.Sum(v => v.Values.Count), @@ -223,7 +219,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable UiSharedService.ByteToString(LastAnalysis.Values.Sum(c => c.Values.Sum(v => v.CompressedSize)))); Logger.LogInformation("IMPORTANT NOTES:\n\r- For Lightless up- and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly."); } - internal sealed record FileDataEntry(string Hash, string FileType, List GamePaths, List FilePaths, long OriginalSize, long CompressedSize, long Triangles) { public bool IsComputed => OriginalSize > 0 && CompressedSize > 0; @@ -243,7 +238,6 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public long OriginalSize { get; private set; } = OriginalSize; public long CompressedSize { get; private set; } = CompressedSize; public long Triangles { get; private set; } = Triangles; - public Lazy Format = new(() => { switch (FileType) diff --git a/LightlessSync/Services/Chat/ChatModels.cs b/LightlessSync/Services/Chat/ChatModels.cs new file mode 100644 index 0000000..f83a7e9 --- /dev/null +++ b/LightlessSync/Services/Chat/ChatModels.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using LightlessSync.API.Dto.Chat; + +namespace LightlessSync.Services.Chat; + +public sealed record ChatMessageEntry( + ChatMessageDto Payload, + string DisplayName, + bool FromSelf, + DateTime ReceivedAtUtc); + +public readonly record struct ChatChannelSnapshot( + string Key, + ChatChannelDescriptor Descriptor, + string DisplayName, + ChatChannelType Type, + bool IsConnected, + bool IsAvailable, + string? StatusText, + bool HasUnread, + int UnreadCount, + IReadOnlyList Messages); diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs new file mode 100644 index 0000000..1aee611 --- /dev/null +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -0,0 +1,1131 @@ +using LightlessSync; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Chat; +using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; +using LightlessSync.Services.Mediator; +using LightlessSync.WebAPI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using LightlessSync.UI.Services; +using LightlessSync.LightlessConfiguration; + +namespace LightlessSync.Services.Chat; + +public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService +{ + private const int MaxMessageHistory = 150; + internal const int MaxOutgoingLength = 400; + private const int MaxUnreadCount = 999; + private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; + private const string ZoneChannelKey = "zone"; + + private readonly ApiController _apiController; + private readonly ChatConfigService _chatConfigService; + private readonly DalamudUtilService _dalamudUtilService; + private readonly ActorObjectService _actorObjectService; + private readonly PairUiService _pairUiService; + + private readonly object _sync = new(); + + private readonly Dictionary _channels = new(StringComparer.Ordinal); + private readonly List _channelOrder = new(); + private readonly Dictionary _territoryToZoneKey = new(); + private readonly Dictionary _zoneDefinitions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _groupDefinitions = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _lastReadCounts = new(StringComparer.Ordinal); + private readonly Dictionary _lastPresenceStates = new(StringComparer.Ordinal); + private readonly Dictionary _selfTokens = new(StringComparer.Ordinal); + private readonly List _pendingSelfMessages = new(); + + private bool _isLoggedIn; + private bool _isConnected; + private ChatChannelDescriptor? _lastZoneDescriptor; + private string? _activeChannelKey; + private bool _chatEnabled = false; + private bool _chatHandlerRegistered; + + public ZoneChatService( + ILogger logger, + LightlessMediator mediator, + ChatConfigService chatConfigService, + ApiController apiController, + DalamudUtilService dalamudUtilService, + ActorObjectService actorObjectService, + PairUiService pairUiService) + : base(logger, mediator) + { + _chatConfigService = chatConfigService; + _apiController = apiController; + _dalamudUtilService = dalamudUtilService; + _actorObjectService = actorObjectService; + _pairUiService = pairUiService; + + _isLoggedIn = _dalamudUtilService.IsLoggedIn; + _isConnected = _apiController.IsConnected; + _chatEnabled = _chatConfigService.Current.AutoEnableChatOnLogin; + } + + public IReadOnlyList GetChannelsSnapshot() + { + lock (_sync) + { + var snapshots = new List(_channelOrder.Count); + foreach (var key in _channelOrder) + { + if (!_channels.TryGetValue(key, out var state)) + continue; + + var statusText = state.StatusText; + if (!_chatEnabled) + { + statusText = "Chat services disabled"; + } + else if (!_isConnected) + { + statusText = "Disconnected from chat server"; + } + + snapshots.Add(new ChatChannelSnapshot( + state.Key, + state.Descriptor, + state.DisplayName, + state.Type, + state.IsConnected, + state.IsConnected && state.IsAvailable, + statusText, + state.HasUnread, + state.UnreadCount, + state.Messages.ToList())); + } + + return snapshots; + } + } + + public bool IsChatEnabled + { + get + { + lock (_sync) + { + return _chatEnabled; + } + } + } + + public bool IsChatConnected + { + get + { + lock (_sync) + { + return _chatEnabled && _isConnected; + } + } + } + + public void SetActiveChannel(string? key) + { + lock (_sync) + { + _activeChannelKey = key; + if (key is not null && _channels.TryGetValue(key, out var state)) + { + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[key] = state.Messages.Count; + } + } + } + + public Task SetChatEnabledAsync(bool enabled) + => enabled ? EnableChatAsync() : DisableChatAsync(); + + private async Task EnableChatAsync() + { + bool wasEnabled; + lock (_sync) + { + wasEnabled = _chatEnabled; + if (!wasEnabled) + { + _chatEnabled = true; + } + } + + if (wasEnabled) + return; + + RegisterChatHandler(); + + await RefreshChatChannelDefinitionsAsync().ConfigureAwait(false); + ScheduleZonePresenceUpdate(force: true); + await EnsureGroupPresenceAsync(force: true).ConfigureAwait(false); + } + + private async Task DisableChatAsync() + { + bool wasEnabled; + List groupDescriptors; + ChatChannelDescriptor? zoneDescriptor; + + lock (_sync) + { + wasEnabled = _chatEnabled; + if (!wasEnabled) + { + return; + } + + _chatEnabled = false; + zoneDescriptor = _lastZoneDescriptor; + _lastZoneDescriptor = null; + + groupDescriptors = _channels.Values + .Where(state => state.Type == ChatChannelType.Group) + .Select(state => state.Descriptor) + .ToList(); + + _selfTokens.Clear(); + _pendingSelfMessages.Clear(); + + foreach (var state in _channels.Values) + { + state.IsConnected = false; + state.IsAvailable = false; + state.StatusText = "Chat services disabled"; + } + } + + UnregisterChatHandler(); + + if (zoneDescriptor.HasValue) + { + await SendPresenceAsync(zoneDescriptor.Value, 0, isActive: false, force: true).ConfigureAwait(false); + } + + foreach (var descriptor in groupDescriptors) + { + await SendPresenceAsync(descriptor, 0, isActive: false, force: true).ConfigureAwait(false); + } + + PublishChannelListChanged(); + } + + public async Task SendMessageAsync(ChatChannelDescriptor descriptor, string message) + { + if (!_chatEnabled) + return false; + + if (string.IsNullOrWhiteSpace(message)) + return false; + + var sanitized = message.Trim().ReplaceLineEndings(" "); + if (sanitized.Length == 0) + return false; + + if (sanitized.Length > MaxOutgoingLength) + sanitized = sanitized[..MaxOutgoingLength]; + + var pendingMessage = EnqueuePendingSelfMessage(descriptor, sanitized); + + try + { + await _apiController.SendChatMessage(new ChatSendRequestDto(descriptor, sanitized)).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + RemovePendingSelfMessage(pendingMessage); + Logger.LogWarning(ex, "Failed to send chat message"); + return false; + } + } + + public Task ResolveParticipantAsync(ChatChannelDescriptor descriptor, string token) + => _apiController.ResolveChatParticipant(new ChatParticipantResolveRequestDto(descriptor, token)); + + public Task StartAsync(CancellationToken cancellationToken) + { + Mediator.Subscribe(this, _ => HandleLogin()); + Mediator.Subscribe(this, _ => HandleLogout()); + Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate()); + Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate(force: true)); + Mediator.Subscribe(this, msg => HandleConnected(msg.Connection)); + Mediator.Subscribe(this, _ => HandleConnected(null)); + Mediator.Subscribe(this, _ => HandleReconnecting()); + Mediator.Subscribe(this, _ => HandleReconnecting()); + Mediator.Subscribe(this, _ => RefreshGroupsFromPairManager()); + Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate(force: true)); + + if (_chatEnabled) + { + RegisterChatHandler(); + _ = RefreshChatChannelDefinitionsAsync(); + ScheduleZonePresenceUpdate(force: true); + _ = EnsureGroupPresenceAsync(force: true); + } + else + { + UpdateChannelsForDisabledState(); + PublishChannelListChanged(); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + UnregisterChatHandler(); + UnsubscribeAll(); + return Task.CompletedTask; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + UnregisterChatHandler(); + UnsubscribeAll(); + } + + base.Dispose(disposing); + } + + private void HandleLogin() + { + _isLoggedIn = true; + if (_chatEnabled) + { + ScheduleZonePresenceUpdate(force: true); + _ = EnsureGroupPresenceAsync(force: true); + } + } + + private void HandleLogout() + { + _isLoggedIn = false; + if (_chatEnabled) + { + ScheduleZonePresenceUpdate(force: true); + } + } + + private void HandleConnected(ConnectionDto? connection) + { + _isConnected = true; + + lock (_sync) + { + _selfTokens.Clear(); + _pendingSelfMessages.Clear(); + + foreach (var state in _channels.Values) + { + state.IsConnected = _chatEnabled; + if (_chatEnabled && state.Type == ChatChannelType.Group) + { + state.IsAvailable = true; + state.StatusText = null; + } + else if (!_chatEnabled) + { + state.IsAvailable = false; + state.StatusText = "Chat services disabled"; + } + } + } + + PublishChannelListChanged(); + + if (_chatEnabled) + { + _ = RefreshChatChannelDefinitionsAsync(); + ScheduleZonePresenceUpdate(force: true); + _ = EnsureGroupPresenceAsync(force: true); + } + } + + private void HandleReconnecting() + { + _isConnected = false; + + lock (_sync) + { + _selfTokens.Clear(); + _pendingSelfMessages.Clear(); + foreach (var state in _channels.Values) + { + state.IsConnected = false; + if (_chatEnabled) + { + state.StatusText = "Disconnected from chat server"; + if (state.Type == ChatChannelType.Group) + { + state.IsAvailable = false; + } + } + else + { + state.StatusText = "Chat services disabled"; + state.IsAvailable = false; + } + } + } + + PublishChannelListChanged(); + } + + private async Task RefreshChatChannelDefinitionsAsync() + { + if (!_chatEnabled) + return; + + try + { + var zones = await _apiController.GetZoneChatChannelsAsync().ConfigureAwait(false); + var groups = await _apiController.GetGroupChatChannelsAsync().ConfigureAwait(false); + + ApplyZoneDefinitions(zones); + ApplyGroupDefinitions(groups); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to refresh chat channel definitions"); + } + } + + private void RegisterChatHandler() + { + if (_chatHandlerRegistered) + return; + + _apiController.RegisterChatMessageHandler(OnChatMessageReceived); + _chatHandlerRegistered = true; + } + + private void UnregisterChatHandler() + { + if (!_chatHandlerRegistered) + return; + + _apiController.UnregisterChatMessageHandler(OnChatMessageReceived); + _chatHandlerRegistered = false; + } + + private void UpdateChannelsForDisabledState() + { + lock (_sync) + { + foreach (var state in _channels.Values) + { + state.IsConnected = false; + state.IsAvailable = false; + state.StatusText = "Chat services disabled"; + } + } + } + + private void ScheduleZonePresenceUpdate(bool force = false) + { + if (!_chatEnabled) + return; + + _ = UpdateZonePresenceAsync(force); + } + + private async Task UpdateZonePresenceAsync(bool force = false) + { + if (!_chatEnabled) + return; + + if (!_isLoggedIn || !_apiController.IsConnected) + { + await LeaveCurrentZoneAsync(force, 0).ConfigureAwait(false); + return; + } + + try + { + var location = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); + var territoryId = (ushort)location.TerritoryId; + + string? zoneKey; + ZoneChannelDefinition? definition = null; + + lock (_sync) + { + _territoryToZoneKey.TryGetValue(territoryId, out zoneKey); + if (zoneKey is not null) + { + _zoneDefinitions.TryGetValue(zoneKey, out var def); + definition = def; + } + } + + if (definition is null) + { + await LeaveCurrentZoneAsync(force, territoryId).ConfigureAwait(false); + return; + } + + var descriptor = await BuildZoneDescriptorAsync(definition.Value).ConfigureAwait(false); + if (descriptor is null) + { + await LeaveCurrentZoneAsync(force, territoryId).ConfigureAwait(false); + return; + } + + bool shouldForceSend; + + lock (_sync) + { + var state = EnsureZoneStateLocked(); + state.DisplayName = definition.Value.DisplayName; + state.Descriptor = descriptor.Value; + state.IsConnected = _chatEnabled && _isConnected; + state.IsAvailable = _chatEnabled; + state.StatusText = _chatEnabled ? null : "Chat services disabled"; + + _activeChannelKey = ZoneChannelKey; + shouldForceSend = force || !_lastZoneDescriptor.HasValue || !ChannelDescriptorsMatch(_lastZoneDescriptor.Value, descriptor.Value); + _lastZoneDescriptor = descriptor; + } + + PublishChannelListChanged(); + await SendPresenceAsync(descriptor.Value, territoryId, isActive: true, force: shouldForceSend).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to update zone chat presence"); + } + } + + private async Task LeaveCurrentZoneAsync(bool force, ushort territoryId) + { + ChatChannelDescriptor? descriptor = null; + bool clearedHistory = false; + + lock (_sync) + { + descriptor = _lastZoneDescriptor; + _lastZoneDescriptor = null; + + if (_channels.TryGetValue(ZoneChannelKey, out var state)) + { + if (state.Messages.Count > 0) + { + state.Messages.Clear(); + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[ZoneChannelKey] = 0; + clearedHistory = true; + } + + state.IsConnected = _isConnected; + state.IsAvailable = false; + state.StatusText = !_chatEnabled + ? "Chat services disabled" + : (_isConnected ? ZoneUnavailableMessage : "Disconnected from chat server"); + state.DisplayName = "Zone Chat"; + } + + if (_activeChannelKey == ZoneChannelKey) + { + _activeChannelKey = _channelOrder.FirstOrDefault(key => key != ZoneChannelKey); + } + } + + if (clearedHistory) + { + PublishHistoryCleared(ZoneChannelKey); + } + + PublishChannelListChanged(); + + if (descriptor.HasValue) + { + await SendPresenceAsync(descriptor.Value, territoryId, isActive: false, force: force).ConfigureAwait(false); + } + } + + private async Task BuildZoneDescriptorAsync(ZoneChannelDefinition definition) + { + try + { + var worldId = (ushort)await _dalamudUtilService.GetWorldIdAsync().ConfigureAwait(false); + return definition.Descriptor with { WorldId = worldId }; + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to obtain world id for zone chat"); + return null; + } + } + + private void ApplyZoneDefinitions(IReadOnlyList? infos) + { + var infoList = infos ?? Array.Empty(); + + lock (_sync) + { + _zoneDefinitions.Clear(); + _territoryToZoneKey.Clear(); + + foreach (var info in infoList) + { + var descriptor = info.Channel.WithNormalizedCustomKey(); + var key = descriptor.CustomKey ?? string.Empty; + if (string.IsNullOrWhiteSpace(key)) + continue; + + var territories = info.Territories? + .SelectMany(EnumerateTerritoryKeys) + .Where(n => n.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + _zoneDefinitions[key] = new ZoneChannelDefinition(key, info.DisplayName ?? key, descriptor, territories); + } + + var territoryData = _dalamudUtilService.TerritoryData.Value; + foreach (var kvp in territoryData) + { + foreach (var variant in EnumerateTerritoryKeys(kvp.Value)) + { + foreach (var def in _zoneDefinitions.Values) + { + if (def.TerritoryNames.Contains(variant)) + { + _territoryToZoneKey[(uint)kvp.Key] = def.Key; + break; + } + } + } + } + + if (_zoneDefinitions.Count == 0) + { + RemoveZoneStateLocked(); + } + else + { + var state = EnsureZoneStateLocked(); + state.DisplayName = "Zone Chat"; + state.IsConnected = _chatEnabled && _isConnected; + state.IsAvailable = false; + state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled"; + } + + UpdateChannelOrderLocked(); + } + + PublishChannelListChanged(); + } + + private void ApplyGroupDefinitions(IReadOnlyList? infos) + { + var infoList = infos ?? Array.Empty(); + var descriptorsToJoin = new List(); + var descriptorsToLeave = new List(); + + lock (_sync) + { + var remainingGroups = new HashSet(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var info in infoList) + { + var descriptor = info.Channel.WithNormalizedCustomKey(); + var groupId = info.GroupId; + if (string.IsNullOrWhiteSpace(groupId)) + continue; + + remainingGroups.Remove(groupId); + + _groupDefinitions[groupId] = new GroupChannelDefinition(groupId, info.DisplayName ?? groupId, descriptor, info.IsOwner); + + var key = BuildChannelKey(descriptor); + if (!_channels.TryGetValue(key, out var state)) + { + state = new ChatChannelState(key, ChatChannelType.Group, info.DisplayName ?? groupId, descriptor); + state.IsConnected = _chatEnabled && _isConnected; + state.IsAvailable = _chatEnabled && _isConnected; + state.StatusText = !_chatEnabled + ? "Chat services disabled" + : (_isConnected ? null : "Disconnected from chat server"); + _channels[key] = state; + _lastReadCounts[key] = 0; + if (_chatEnabled) + { + descriptorsToJoin.Add(descriptor); + } + } + else + { + state.DisplayName = info.DisplayName ?? groupId; + state.Descriptor = descriptor; + state.IsConnected = _chatEnabled && _isConnected; + state.IsAvailable = _chatEnabled && _isConnected; + state.StatusText = !_chatEnabled + ? "Chat services disabled" + : (_isConnected ? null : "Disconnected from chat server"); + } + } + + foreach (var removedGroupId in remainingGroups) + { + if (_groupDefinitions.TryGetValue(removedGroupId, out var definition)) + { + var key = BuildChannelKey(definition.Descriptor); + if (_channels.TryGetValue(key, out var state)) + { + descriptorsToLeave.Add(state.Descriptor); + _channels.Remove(key); + _lastReadCounts.Remove(key); + _lastPresenceStates.Remove(BuildPresenceKey(state.Descriptor)); + _selfTokens.Remove(key); + _pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, key, StringComparison.Ordinal)); + if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) + { + _activeChannelKey = null; + } + } + + _groupDefinitions.Remove(removedGroupId); + } + } + + UpdateChannelOrderLocked(); + } + + foreach (var descriptor in descriptorsToLeave) + { + _ = SendPresenceAsync(descriptor, 0, isActive: false, force: true); + } + + foreach (var descriptor in descriptorsToJoin) + { + _ = SendPresenceAsync(descriptor, 0, isActive: true, force: true); + } + + PublishChannelListChanged(); + } + + private void RefreshGroupsFromPairManager() + { + var snapshot = _pairUiService.GetSnapshot(); + var groups = snapshot.Groups.ToList(); + if (groups.Count == 0) + { + ApplyGroupDefinitions(Array.Empty()); + return; + } + + var infos = new List(groups.Count); + foreach (var group in groups) + { + var descriptor = new ChatChannelDescriptor + { + Type = ChatChannelType.Group, + WorldId = 0, + ZoneId = 0, + CustomKey = group.Group.GID + }; + + var displayName = string.IsNullOrWhiteSpace(group.Group.Alias) ? group.Group.GID : group.Group.Alias; + var isOwner = string.Equals(group.Owner.UID, _apiController.UID, StringComparison.Ordinal); + + infos.Add(new GroupChatChannelInfoDto(descriptor, displayName, group.Group.GID, isOwner)); + } + + ApplyGroupDefinitions(infos); + } + + private async Task EnsureGroupPresenceAsync(bool force = false) + { + if (!_chatEnabled) + return; + + List descriptors; + lock (_sync) + { + descriptors = _channels.Values + .Where(state => state.Type == ChatChannelType.Group) + .Select(state => state.Descriptor) + .ToList(); + } + + foreach (var descriptor in descriptors) + { + await SendPresenceAsync(descriptor, 0, isActive: true, force: force).ConfigureAwait(false); + } + } + + private async Task SendPresenceAsync(ChatChannelDescriptor descriptor, ushort territoryId, bool isActive, bool force) + { + if (!_apiController.IsConnected) + return; + + if (!_chatEnabled && isActive) + return; + + var presenceKey = BuildPresenceKey(descriptor); + bool stateMatches; + + lock (_sync) + { + stateMatches = !force + && _lastPresenceStates.TryGetValue(presenceKey, out var lastState) + && lastState == isActive; + } + + if (stateMatches) + return; + + try + { + await _apiController.UpdateChatPresence(new ChatPresenceUpdateDto(descriptor, territoryId, isActive)).ConfigureAwait(false); + + lock (_sync) + { + if (isActive) + { + _lastPresenceStates[presenceKey] = true; + } + else + { + _lastPresenceStates.Remove(presenceKey); + } + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to update chat presence"); + } + } + + private PendingSelfMessage EnqueuePendingSelfMessage(ChatChannelDescriptor descriptor, string message) + { + var normalized = descriptor.WithNormalizedCustomKey(); + var key = normalized.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(normalized); + var pending = new PendingSelfMessage(key, message); + + lock (_sync) + { + _pendingSelfMessages.Add(pending); + while (_pendingSelfMessages.Count > 20) + { + _pendingSelfMessages.RemoveAt(0); + } + } + + return pending; + } + + private void RemovePendingSelfMessage(PendingSelfMessage pending) + { + lock (_sync) + { + var index = _pendingSelfMessages.FindIndex(p => + string.Equals(p.ChannelKey, pending.ChannelKey, StringComparison.Ordinal) && + string.Equals(p.Message, pending.Message, StringComparison.Ordinal)); + + if (index >= 0) + { + _pendingSelfMessages.RemoveAt(index); + } + } + } + + private void OnChatMessageReceived(ChatMessageDto dto) + { + var descriptor = dto.Channel.WithNormalizedCustomKey(); + var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); + var fromSelf = IsMessageFromSelf(dto, key); + var message = BuildMessage(dto, fromSelf); + bool publishChannelList = false; + + lock (_sync) + { + if (!_channels.TryGetValue(key, out var state)) + { + var displayName = descriptor.Type switch + { + ChatChannelType.Zone => _zoneDefinitions.TryGetValue(descriptor.CustomKey ?? string.Empty, out var def) + ? def.DisplayName + : "Zone Chat", + ChatChannelType.Group => descriptor.CustomKey ?? "Syncshell", + _ => descriptor.CustomKey ?? "Chat" + }; + + state = new ChatChannelState( + key, + descriptor.Type, + displayName, + descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor); + + state.IsConnected = _isConnected; + state.IsAvailable = descriptor.Type == ChatChannelType.Group && _isConnected; + state.StatusText = descriptor.Type == ChatChannelType.Zone ? ZoneUnavailableMessage : (_isConnected ? null : "Disconnected from chat server"); + + _channels[key] = state; + _lastReadCounts[key] = 0; + publishChannelList = true; + } + + state.Descriptor = descriptor.Type == ChatChannelType.Zone ? (_lastZoneDescriptor ?? descriptor) : descriptor; + state.Messages.Add(message); + if (state.Messages.Count > MaxMessageHistory) + { + state.Messages.RemoveAt(0); + } + + if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) + { + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[key] = state.Messages.Count; + } + else + { + var lastRead = _lastReadCounts.TryGetValue(key, out var readCount) ? readCount : 0; + var unreadFromHistory = Math.Max(0, state.Messages.Count - lastRead); + var incrementalUnread = Math.Min(state.UnreadCount + 1, MaxUnreadCount); + state.UnreadCount = Math.Min(Math.Max(unreadFromHistory, incrementalUnread), MaxUnreadCount); + state.HasUnread = state.UnreadCount > 0; + } + } + + Mediator.Publish(new ChatChannelMessageAdded(key, message)); + + if (publishChannelList) + { + lock (_sync) + { + UpdateChannelOrderLocked(); + } + + PublishChannelListChanged(); + } + } + + private bool IsMessageFromSelf(ChatMessageDto dto, string channelKey) + { + if (dto.Sender.User?.UID is { } uid && string.Equals(uid, _apiController.UID, StringComparison.Ordinal)) + { + lock (_sync) + { + _selfTokens[channelKey] = dto.Sender.Token; + } + + return true; + } + + lock (_sync) + { + if (_selfTokens.TryGetValue(channelKey, out var token) && + string.Equals(token, dto.Sender.Token, StringComparison.Ordinal)) + { + return true; + } + + var index = _pendingSelfMessages.FindIndex(p => + string.Equals(p.ChannelKey, channelKey, StringComparison.Ordinal) && + string.Equals(p.Message, dto.Message, StringComparison.Ordinal)); + + if (index >= 0) + { + _pendingSelfMessages.RemoveAt(index); + _selfTokens[channelKey] = dto.Sender.Token; + return true; + } + } + + return false; + } + + private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf) + { + var displayName = ResolveDisplayName(dto, fromSelf); + return new ChatMessageEntry(dto, displayName, fromSelf, DateTime.UtcNow); + } + + private string ResolveDisplayName(ChatMessageDto dto, bool fromSelf) + { + var isZone = dto.Channel.Type == ChatChannelType.Zone; + if (!string.IsNullOrEmpty(dto.Sender.HashedCid) && + _actorObjectService.TryGetActorByHash(dto.Sender.HashedCid, out var descriptor) && + !string.IsNullOrWhiteSpace(descriptor.Name)) + { + return descriptor.Name; + } + + if (fromSelf && isZone && dto.Sender.CanResolveProfile) + { + try + { + return _dalamudUtilService.GetPlayerNameAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to resolve self name for chat message"); + } + } + + if (dto.Sender.Kind == ChatSenderKind.IdentifiedUser && dto.Sender.User is not null) + { + return dto.Sender.User.AliasOrUID; + } + + if (!string.IsNullOrWhiteSpace(dto.Sender.DisplayName)) + { + return dto.Sender.DisplayName!; + } + + return dto.Sender.Token; + } + + private void UpdateChannelOrderLocked() + { + _channelOrder.Clear(); + + if (_channels.ContainsKey(ZoneChannelKey)) + { + _channelOrder.Add(ZoneChannelKey); + } + + var groups = _channels.Values + .Where(state => state.Type == ChatChannelType.Group) + .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(state => state.Key); + + _channelOrder.AddRange(groups); + + if (_activeChannelKey is null && _channelOrder.Count > 0) + { + _activeChannelKey = _channelOrder[0]; + } + else if (_activeChannelKey is not null && !_channelOrder.Contains(_activeChannelKey)) + { + _activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null; + } + } + + private void PublishChannelListChanged() => Mediator.Publish(new ChatChannelsUpdated()); + + private void PublishHistoryCleared(string channelKey) => Mediator.Publish(new ChatChannelHistoryCleared(channelKey)); + + private static IEnumerable EnumerateTerritoryKeys(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + yield break; + + var normalizedFull = NormalizeKey(value); + if (normalizedFull.Length > 0) + yield return normalizedFull; + + var segments = value.Split('-', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (segments.Length <= 1) + yield break; + + for (var i = 1; i < segments.Length; i++) + { + var composite = string.Join(" - ", segments[i..]); + var normalized = NormalizeKey(composite); + if (normalized.Length > 0) + yield return normalized; + } + } + + private static string NormalizeKey(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToUpperInvariant(); + + private static string BuildChannelKey(ChatChannelDescriptor descriptor) + => $"{(int)descriptor.Type}:{NormalizeKey(descriptor.CustomKey)}"; + + private static string BuildPresenceKey(ChatChannelDescriptor descriptor) + => $"{(int)descriptor.Type}:{descriptor.WorldId}:{NormalizeKey(descriptor.CustomKey)}"; + + private static bool ChannelDescriptorsMatch(ChatChannelDescriptor left, ChatChannelDescriptor right) + => left.Type == right.Type + && NormalizeKey(left.CustomKey) == NormalizeKey(right.CustomKey) + && left.WorldId == right.WorldId; + + private ChatChannelState EnsureZoneStateLocked() + { + if (!_channels.TryGetValue(ZoneChannelKey, out var state)) + { + state = new ChatChannelState(ZoneChannelKey, ChatChannelType.Zone, "Zone Chat", new ChatChannelDescriptor { Type = ChatChannelType.Zone }); + state.IsConnected = _chatEnabled && _isConnected; + state.IsAvailable = false; + state.StatusText = _chatEnabled ? ZoneUnavailableMessage : "Chat services disabled"; + _channels[ZoneChannelKey] = state; + _lastReadCounts[ZoneChannelKey] = 0; + UpdateChannelOrderLocked(); + } + + return state; + } + + private void RemoveZoneStateLocked() + { + if (_channels.Remove(ZoneChannelKey)) + { + _lastReadCounts.Remove(ZoneChannelKey); + _lastPresenceStates.Remove(BuildPresenceKey(new ChatChannelDescriptor { Type = ChatChannelType.Zone })); + _selfTokens.Remove(ZoneChannelKey); + _pendingSelfMessages.RemoveAll(p => string.Equals(p.ChannelKey, ZoneChannelKey, StringComparison.Ordinal)); + if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) + { + _activeChannelKey = null; + } + UpdateChannelOrderLocked(); + } + } + + private sealed class ChatChannelState + { + public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) + { + Key = key; + Type = type; + DisplayName = displayName; + Descriptor = descriptor; + Messages = new List(); + } + + public string Key { get; } + public ChatChannelType Type { get; } + public string DisplayName { get; set; } + public ChatChannelDescriptor Descriptor { get; set; } + public bool IsConnected { get; set; } + public bool IsAvailable { get; set; } + public string? StatusText { get; set; } + public bool HasUnread { get; set; } + public int UnreadCount { get; set; } + public List Messages { get; } + } + + private readonly record struct ZoneChannelDefinition( + string Key, + string DisplayName, + ChatChannelDescriptor Descriptor, + HashSet TerritoryNames); + + private readonly record struct GroupChannelDefinition( + string GroupId, + string DisplayName, + ChatChannelDescriptor Descriptor, + bool IsOwner); + + private readonly record struct PendingSelfMessage(string ChannelKey, string Message); +} + + + diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 464fee1..075a704 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -1,12 +1,15 @@ +using LightlessSync; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.Gui.ContextMenu; using Dalamud.Plugin; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; -using LightlessSync.PlayerData.Pairs; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; using Lumina.Excel.Sheets; +using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -20,11 +23,15 @@ internal class ContextMenuService : IHostedService private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtil; private readonly IClientState _clientState; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly PairRequestService _pairRequestService; private readonly ApiController _apiController; private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; + private readonly BroadcastScannerService _broadcastScannerService; + private readonly BroadcastService _broadcastService; + private readonly LightlessProfileManager _lightlessProfileManager; + private readonly LightlessMediator _mediator; public ContextMenuService( IContextMenu contextMenu, @@ -36,8 +43,12 @@ internal class ContextMenuService : IHostedService IObjectTable objectTable, LightlessConfigService configService, PairRequestService pairRequestService, - PairManager pairManager, - IClientState clientState) + PairUiService pairUiService, + IClientState clientState, + BroadcastScannerService broadcastScannerService, + BroadcastService broadcastService, + LightlessProfileManager lightlessProfileManager, + LightlessMediator mediator) { _contextMenu = contextMenu; _pluginInterface = pluginInterface; @@ -47,9 +58,13 @@ internal class ContextMenuService : IHostedService _apiController = apiController; _objectTable = objectTable; _configService = configService; - _pairManager = pairManager; + _pairUiService = pairUiService; _pairRequestService = pairRequestService; _clientState = clientState; + _broadcastScannerService = broadcastScannerService; + _broadcastService = broadcastService; + _lightlessProfileManager = lightlessProfileManager; + _mediator = mediator; } public Task StartAsync(CancellationToken cancellationToken) @@ -78,42 +93,67 @@ internal class ContextMenuService : IHostedService private void OnMenuOpened(IMenuOpenedArgs args) { - if (!_pluginInterface.UiBuilder.ShouldModifyUi) return; if (args.AddonName != null) return; - - //Check if target is not menutargetdefault. + if (args.Target is not MenuTargetDefault target) return; - //Check if name or target id isnt null/zero if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) return; - //Check if it is a real target. IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); if (targetData == null || targetData.Address == nint.Zero) return; - //Check if user is directly paired or is own. - if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId) + if (!_configService.Current.EnableRightClickMenus) + return; + + var snapshot = _pairUiService.GetSnapshot(); + var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => + p.IsVisible && + p.PlayerCharacterId != uint.MaxValue && + (ulong)p.PlayerCharacterId == target.TargetObjectId); + + if (pair is not null) + { + pair.AddContextMenu(args); + return; + } + + //Check if user is directly paired or is own. + if (VisibleUserIds.Contains(target.TargetObjectId) || (_clientState.LocalPlayer?.GameObjectId ?? 0) == target.TargetObjectId) return; - //Check if in PVP or GPose if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing) return; - //Check for valid world. var world = GetWorld(target.TargetHomeWorld.RowId); if (!IsWorldValid(world)) return; - if (!_configService.Current.EnableRightClickMenus) - return; - + string? targetHashedCid = null; + if (_broadcastService.IsBroadcasting) + { + targetHashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); + } + + if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid)) + { + var hashedCid = targetHashedCid; + args.AddMenuItem(new MenuItem + { + Name = "Open Lightless Profile", + PrefixChar = 'L', + UseDefaultPrefix = false, + PrefixColor = 708, + OnClicked = async _ => await HandleLightfinderProfileSelection(hashedCid!).ConfigureAwait(false) + }); + } + args.AddMenuItem(new MenuItem { Name = "Send Direct Pair Request", @@ -124,6 +164,12 @@ internal class ContextMenuService : IHostedService }); } + private HashSet VisibleUserIds => + _pairUiService.GetSnapshot().PairsByUid.Values + .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) + .Select(p => (ulong)p.PlayerCharacterId) + .ToHashSet(); + private async Task HandleSelection(IMenuArgs args) { if (args.Target is not MenuTargetDefault target) @@ -159,9 +205,48 @@ internal class ContextMenuService : IHostedService } } - private HashSet VisibleUserIds => [.. _pairManager.DirectPairs - .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) - .Select(u => (ulong)u.PlayerCharacterId)]; + private async Task HandleLightfinderProfileSelection(string hashedCid) + { + if (string.IsNullOrWhiteSpace(hashedCid)) + return; + + if (!_broadcastService.IsBroadcasting) + { + Notify("Lightfinder inactive", "Enable Lightfinder to open broadcaster profiles.", NotificationType.Warning, 6); + return; + } + + if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry) || !entry.IsBroadcasting || entry.ExpiryTime <= DateTime.UtcNow) + { + Notify("Broadcaster unavailable", "That player is not currently using Lightfinder.", NotificationType.Info, 5); + return; + } + + var result = await _lightlessProfileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false); + if (result == null) + { + Notify("Profile unavailable", "Unable to load Lightless profile for that player.", NotificationType.Error, 6); + return; + } + + _mediator.Publish(new OpenLightfinderProfileMessage(result.Value.User, result.Value.ProfileData, hashedCid)); + } + + private void Notify(string title, string message, NotificationType type, double durationSeconds) + { + _mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds))); + } + + private bool CanOpenLightfinderProfile(string hashedCid) + { + if (!_broadcastService.IsBroadcasting) + return false; + + if (!_broadcastScannerService.BroadcastCache.TryGetValue(hashedCid, out var entry)) + return false; + + return entry.IsBroadcasting && entry.ExpiryTime > DateTime.UtcNow; + } private IPlayerCharacter? GetPlayerFromObjectTable(MenuTargetDefault target) { diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index e5fd735..716523d 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -12,15 +12,20 @@ using FFXIVClientStructs.FFXIV.Client.UI.Agent; using LightlessSync.API.Dto.CharaData; using LightlessSync.Interop; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Lumina.Excel.Sheets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Linq; using System.Numerics; using System.Runtime.CompilerServices; using System.Text; +using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; namespace LightlessSync.Services; @@ -37,23 +42,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private readonly IGameGui _gameGui; private readonly ILogger _logger; private readonly IObjectTable _objectTable; + private readonly ActorObjectService _actorObjectService; private readonly PerformanceCollectorService _performanceCollector; private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly Lazy _pairFactory; private uint? _classJobId = 0; private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow; private string _lastGlobalBlockPlayer = string.Empty; private string _lastGlobalBlockReason = string.Empty; private ushort _lastZone = 0; - private readonly Dictionary _playerCharas = new(StringComparer.Ordinal); - private readonly List _notUpdatedCharas = []; + private ushort _lastWorldId = 0; private bool _sentBetweenAreas = false; private Lazy _cid; public DalamudUtilService(ILogger logger, IClientState clientState, IObjectTable objectTable, IFramework framework, IGameGui gameGui, ICondition condition, IDataManager gameData, ITargetManager targetManager, IGameConfig gameConfig, - BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector, - LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService) + ActorObjectService actorObjectService, BlockedCharacterHandler blockedCharacterHandler, LightlessMediator mediator, PerformanceCollectorService performanceCollector, + LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, Lazy pairFactory) { _logger = logger; _clientState = clientState; @@ -63,11 +69,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _condition = condition; _gameData = gameData; _gameConfig = gameConfig; + _actorObjectService = actorObjectService; _blockedCharacterHandler = blockedCharacterHandler; Mediator = mediator; _performanceCollector = performanceCollector; _configService = configService; _playerPerformanceConfigService = playerPerformanceConfigService; + _pairFactory = pairFactory; WorldData = new(() => { return gameData.GetExcelSheet(Dalamud.Game.ClientLanguage.English)! @@ -119,9 +127,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber mediator.Subscribe(this, (msg) => { if (clientState.IsPvP) return; - var name = msg.Pair.PlayerName; + var pair = _pairFactory.Value.Create(msg.Pair.UniqueIdent) ?? msg.Pair; + var name = pair.PlayerName; if (string.IsNullOrEmpty(name)) return; - var addr = _playerCharas.FirstOrDefault(f => string.Equals(f.Value.Name, name, StringComparison.Ordinal)).Value.Address; + if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor)) + return; + var addr = descriptor.Address; if (addr == nint.Zero) return; var useFocusTarget = _configService.Current.UseFocusTarget; _ = RunOnFrameworkThread(() => @@ -194,7 +205,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { EnsureIsOnFramework(); var objTableObj = _objectTable[index]; - if (objTableObj!.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) return null; + if (objTableObj!.ObjectKind != DalamudObjectKind.Player) return null; return (ICharacter)objTableObj; } @@ -226,7 +237,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IEnumerable GetGposeCharactersFromObjectTable() { - return _objectTable.Where(o => o.ObjectIndex > 200 && o.ObjectKind == Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player).Cast(); + foreach (var actor in _actorObjectService.PlayerDescriptors + .Where(a => a.ObjectKind == DalamudObjectKind.Player && a.ObjectIndex > 200)) + { + var character = _objectTable.CreateObjectReference(actor.Address) as ICharacter; + if (character != null) + yield return character; + } } public bool GetIsPlayerPresent() @@ -281,7 +298,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName) { - if (_playerCharas.TryGetValue(characterName, out var pchar)) return pchar.Address; + if (_actorObjectService.TryGetActorByHash(characterName, out var actor)) + return actor.Address; return IntPtr.Zero; } @@ -552,8 +570,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber internal (string Name, nint Address) FindPlayerByNameHash(string ident) { - _playerCharas.TryGetValue(ident, out var result); - return result; + if (_actorObjectService.TryGetActorByHash(ident, out var descriptor)) + { + return (descriptor.Name, descriptor.Address); + } + + return default; } public string? GetWorldNameFromPlayerAddress(nint address) @@ -639,37 +661,43 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _performanceCollector.LogPerformance(this, $"FrameworkOnUpdateInternal+{(isNormalFrameworkUpdate ? "Regular" : "Delayed")}", () => { IsAnythingDrawing = false; - _performanceCollector.LogPerformance(this, $"ObjTableToCharas", + _performanceCollector.LogPerformance(this, $"TrackedActorsToState", () => { - _notUpdatedCharas.AddRange(_playerCharas.Keys); + _actorObjectService.RefreshTrackedActors(); - for (int i = 0; i < 200; i += 2) + var playerDescriptors = _actorObjectService.PlayerCharacterDescriptors; + for (var i = 0; i < playerDescriptors.Count; i++) { - var chara = _objectTable[i]; - if (chara == null || chara.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) + var actor = playerDescriptors[i]; + + var playerAddress = actor.Address; + if (playerAddress == nint.Zero) continue; - if (_blockedCharacterHandler.IsCharacterBlocked(chara.Address, out bool firstTime) && firstTime) + if (actor.ObjectIndex >= 200) + continue; + + if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime) { - _logger.LogTrace("Skipping character {addr}, blocked/muted", chara.Address.ToString("X")); + _logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X")); continue; } - var charaName = ((GameObject*)chara.Address)->NameString; - var hash = GetHashedCIDFromPlayerPointer(chara.Address); if (!IsAnythingDrawing) - CheckCharacterForDrawing(chara.Address, charaName); - _notUpdatedCharas.Remove(hash); - _playerCharas[hash] = (charaName, chara.Address); + { + var gameObj = (GameObject*)playerAddress; + var currentName = gameObj != null ? gameObj->NameString ?? string.Empty : string.Empty; + var charaName = string.IsNullOrEmpty(currentName) ? actor.Name : currentName; + CheckCharacterForDrawing(playerAddress, charaName); + if (IsAnythingDrawing) + break; + } + else + { + break; + } } - - foreach (var notUpdatedChara in _notUpdatedCharas) - { - _playerCharas.Remove(notUpdatedChara); - } - - _notUpdatedCharas.Clear(); }); if (!IsAnythingDrawing && !string.IsNullOrEmpty(_lastGlobalBlockPlayer)) @@ -786,6 +814,18 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (localPlayer != null) { _classJobId = localPlayer.ClassJob.RowId; + + var currentWorldId = (ushort)localPlayer.CurrentWorld.RowId; + if (currentWorldId != _lastWorldId) + { + var previousWorldId = _lastWorldId; + _lastWorldId = currentWorldId; + Mediator.Publish(new WorldChangedMessage(previousWorldId, currentWorldId)); + } + } + else if (_lastWorldId != 0) + { + _lastWorldId = 0; } if (!IsInCombat || !IsPerforming || !IsInInstance) @@ -801,6 +841,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _logger.LogDebug("Logged in"); IsLoggedIn = true; _lastZone = _clientState.TerritoryType; + _lastWorldId = (ushort)localPlayer.CurrentWorld.RowId; _cid = RebuildCID(); Mediator.Publish(new DalamudLoginMessage()); } @@ -808,6 +849,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { _logger.LogDebug("Logged out"); IsLoggedIn = false; + _lastWorldId = 0; Mediator.Publish(new DalamudLogoutMessage()); } diff --git a/LightlessSync/Services/LightlessGroupProfileData.cs b/LightlessSync/Services/LightlessGroupProfileData.cs index 1b27b40..eb77175 100644 --- a/LightlessSync/Services/LightlessGroupProfileData.cs +++ b/LightlessSync/Services/LightlessGroupProfileData.cs @@ -1,6 +1,20 @@ -namespace LightlessSync.Services; +using System; +using System.Collections.Generic; -public record LightlessGroupProfileData(string Base64ProfilePicture, string Description, int[] Tags, bool IsNsfw, bool IsDisabled) +namespace LightlessSync.Services; + +public record LightlessGroupProfileData( + bool IsDisabled, + bool IsNsfw, + string Base64ProfilePicture, + string Base64BannerPicture, + string Description, + IReadOnlyList Tags) { - public Lazy ImageData { get; } = new Lazy(Convert.FromBase64String(Base64ProfilePicture)); + public Lazy ProfileImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture)); + public Lazy BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture)); + + private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) + ? Array.Empty() + : Convert.FromBase64String(value); } diff --git a/LightlessSync/Services/LightlessProfileData.cs b/LightlessSync/Services/LightlessProfileData.cs new file mode 100644 index 0000000..ef62862 --- /dev/null +++ b/LightlessSync/Services/LightlessProfileData.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace LightlessSync.Services; + +public record LightlessProfileData( + bool IsFlagged, + bool IsNSFW, + string Base64ProfilePicture, + string Base64SupporterPicture, + string Base64BannerPicture, + string Description, + IReadOnlyList Tags) +{ + public Lazy ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture)); + public Lazy SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture)); + public Lazy BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture)); + + private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty() : Convert.FromBase64String(value); +} diff --git a/LightlessSync/Services/LightlessProfileManager.cs b/LightlessSync/Services/LightlessProfileManager.cs index 00b610b..0895078 100644 --- a/LightlessSync/Services/LightlessProfileManager.cs +++ b/LightlessSync/Services/LightlessProfileManager.cs @@ -1,10 +1,12 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; +using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using Serilog.Core; +using System; using System.Collections.Concurrent; namespace LightlessSync.Services; @@ -15,7 +17,8 @@ public class LightlessProfileManager : MediatorSubscriberBase private const string _lightlessLogo = "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAACXBIWXMAAAsTAAALEwEAmpwYAAAGlmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgOS4xLWMwMDIgNzkuZGJhM2RhMywgMjAyMy8xMi8xMy0wNTowNjo0OSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIiB4bWxuczpwaG90b3Nob3A9Imh0dHA6Ly9ucy5hZG9iZS5jb20vcGhvdG9zaG9wLzEuMC8iIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIDI2LjggKFdpbmRvd3MpIiB4bXA6Q3JlYXRlRGF0ZT0iMjAyNS0wOC0zMFQwMDo1MTozNCswMTowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyNS0wOC0zMFQwMjoxMjo0MiswMjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjUtMDgtMzBUMDI6MTI6NDIrMDI6MDAiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Y2JjY2YwZDctNTU4ZS1kYzQ1LTk0MmQtZTg5YWE5ZWRhYjUzIiB4bXBNTTpEb2N1bWVudElEPSJhZG9iZTpkb2NpZDpwaG90b3Nob3A6Nzk1NDcwMjYtYjM2OC1jMjRhLTliYzEtYmQzMjE4OTJlN2FiIiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MGRiOTU0NTktM2E2OS0xNDQ2LWFmYmMtM2M4ODhjZDUxYmU0IiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyI+IDx4bXBNTTpIaXN0b3J5PiA8cmRmOlNlcT4gPHJkZjpsaSBzdEV2dDphY3Rpb249ImNyZWF0ZWQiIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MGRiOTU0NTktM2E2OS0xNDQ2LWFmYmMtM2M4ODhjZDUxYmU0IiBzdEV2dDp3aGVuPSIyMDI1LTA4LTMwVDAwOjUxOjM0KzAxOjAwIiBzdEV2dDpzb2Z0d2FyZUFnZW50PSJBZG9iZSBQaG90b3Nob3AgMjYuOCAoV2luZG93cykiLz4gPHJkZjpsaSBzdEV2dDphY3Rpb249InNhdmVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOmE5OWYwOGE5LTgxNDItY2E0Mi04MmY1LTM2MjZmOTgzNDZhYiIgc3RFdnQ6d2hlbj0iMjAyNS0wOC0zMFQwMDo1MTozNCswMTowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIDI2LjggKFdpbmRvd3MpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjYmNjZjBkNy01NThlLWRjNDUtOTQyZC1lODlhYTllZGFiNTMiIHN0RXZ0OndoZW49IjIwMjUtMDgtMzBUMDI6MTI6NDIrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCAyNS43IChXaW5kb3dzKSIgc3RFdnQ6Y2hhbmdlZD0iLyIvPiA8L3JkZjpTZXE+IDwveG1wTU06SGlzdG9yeT4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5CIHRlAAEVd0lEQVR42uz9d7xtWVXmD3/HnHOttcPJ5+ZYOVAUFDlnEBQwKybs1jaDoj9b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"; private const string _lightlessLogoLoading = "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"; private const string _lightlessLogoNsfw = "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"; - private const string _lightlessSupporter = "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"; + private const string _lightlessSupporter = "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"; + private const string _lightlessBanner = "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"; private const string _noUserDescription = "-- User has no description set --"; private const string _noGroupDescription = "-- Syncshell has no description set --"; private const string _nsfwDescription = "Profile not displayed - NSFW"; @@ -26,12 +29,78 @@ public class LightlessProfileManager : MediatorSubscriberBase private readonly ConcurrentDictionary _lightlessUserProfiles = new(UserDataComparer.Instance); private readonly ConcurrentDictionary _lightlessGroupProfiles = new(GroupDataComparer.Instance); - private readonly LightlessUserProfileData _defaultProfileUserData = new(IsFlagged: false, IsNSFW: false, _lightlessLogo, string.Empty, _noUserDescription); - private readonly LightlessUserProfileData _loadingProfileUserData = new(IsFlagged: false, IsNSFW: false, _lightlessLogoLoading, string.Empty, _loadingData); - private readonly LightlessGroupProfileData _loadingProfileGroupData = new(_lightlessLogoLoading, _loadingData, [], IsNsfw: false, IsDisabled: false); - private readonly LightlessGroupProfileData _defaultProfileGroupData = new(_lightlessLogo, _noGroupDescription, [], IsNsfw: false, IsDisabled: false); - private readonly LightlessUserProfileData _nsfwProfileUserData = new(IsFlagged: false, IsNSFW: true, _lightlessLogoNsfw, string.Empty, _nsfwDescription); - private readonly LightlessGroupProfileData _nsfwProfileGroupData = new(_lightlessLogoNsfw, _nsfwDescription, [], IsNsfw: false, IsDisabled: false); + private static readonly int[] _emptyTagSet = Array.Empty(); + private readonly LightlessUserProfileData _defaultProfileUserData = new( + IsFlagged: false, + IsNSFW: false, + Base64ProfilePicture: _lightlessLogo, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: _lightlessBanner, + Description: _noUserDescription, + Tags: _emptyTagSet); + private readonly LightlessUserProfileData _loadingProfileUserData = new( + IsFlagged: false, + IsNSFW: false, + Base64ProfilePicture: _lightlessLogoLoading, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: _lightlessBanner, + Description: _loadingData, + Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _loadingProfileGroupData = new( + IsDisabled: false, + IsNsfw: false, + Base64ProfilePicture: _lightlessLogoLoading, + Base64BannerPicture: _lightlessBanner, + Description: _loadingData, + Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _defaultProfileGroupData = new( + IsDisabled: false, + IsNsfw: false, + Base64ProfilePicture: _lightlessLogo, + Base64BannerPicture: _lightlessBanner, + Description: _noGroupDescription, + Tags: _emptyTagSet); + private readonly LightlessUserProfileData _nsfwProfileUserData = new( + IsFlagged: false, + IsNSFW: true, + Base64ProfilePicture: _lightlessLogoNsfw, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: string.Empty, + Description: _nsfwDescription, + Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _nsfwProfileGroupData = new( + IsDisabled: false, + IsNsfw: true, + Base64ProfilePicture: _lightlessLogoNsfw, + Base64BannerPicture: string.Empty, + Description: _nsfwDescription, + Tags: _emptyTagSet); + private const string _noDescription = "-- Profile has no description set --"; + private readonly ConcurrentDictionary _lightlessProfiles = new(UserDataComparer.Instance); + private readonly LightlessProfileData _defaultProfileData = new( + IsFlagged: false, + IsNSFW: false, + Base64ProfilePicture: _lightlessLogo, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: _lightlessBanner, + Description: _noDescription, + Tags: _emptyTagSet); + private readonly LightlessProfileData _loadingProfileData = new( + IsFlagged: false, + IsNSFW: false, + Base64ProfilePicture: _lightlessLogoLoading, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: _lightlessBanner, + Description: _loadingData, + Tags: _emptyTagSet); + private readonly LightlessProfileData _nsfwProfileData = new( + IsFlagged: false, + IsNSFW: false, + Base64ProfilePicture: _lightlessLogoNsfw, + Base64SupporterPicture: string.Empty, + Base64BannerPicture: string.Empty, + Description: _nsfwDescription, + Tags: _emptyTagSet); public LightlessProfileManager(ILogger logger, LightlessConfigService lightlessConfigService, LightlessMediator mediator, ApiController apiController) : base(logger, mediator) @@ -46,11 +115,13 @@ public class LightlessProfileManager : MediatorSubscriberBase { _logger.LogTrace("Received Clear Profile for User profile {data}", msg.UserData.AliasOrUID); _lightlessUserProfiles.Remove(msg.UserData, out _); + _lightlessProfiles.Remove(msg.UserData, out _); } else { _logger.LogTrace("Received Clear Profile for all User profiles"); _lightlessUserProfiles.Clear(); + _lightlessProfiles.Clear(); } }); @@ -74,6 +145,7 @@ public class LightlessProfileManager : MediatorSubscriberBase _logger.LogTrace("Received Disconnect, Clearing Profiles"); _lightlessUserProfiles.Clear(); _lightlessGroupProfiles.Clear(); + _lightlessProfiles.Clear(); } ); } @@ -95,6 +167,18 @@ public class LightlessProfileManager : MediatorSubscriberBase return (profile); } + public LightlessProfileData GetLightlessProfile(UserData data) + { + if (!_lightlessProfiles.TryGetValue(data, out var profile)) + { + _logger.LogTrace("Requesting Lightless profile for {data}", data); + _ = Task.Run(() => GetLightlessProfileFromService(data)); + return _loadingProfileData; + } + + return profile; + } + /// /// Fetches Group Profile from cache or API @@ -124,21 +208,32 @@ public class LightlessProfileManager : MediatorSubscriberBase { _logger.LogTrace("Inputting loading data in _lightlessUserProfiles for User {data}", data.AliasOrUID); _lightlessUserProfiles[data] = _loadingProfileUserData; + _lightlessProfiles[data] = _loadingProfileData; var profile = await _apiController.UserGetProfile(new API.Dto.User.UserDto(data)).ConfigureAwait(false); - - LightlessUserProfileData profileUserData = new(profile.Disabled, profile.IsNSFW ?? false, - string.IsNullOrEmpty(profile.ProfilePictureBase64) ? _lightlessLogo : profile.ProfilePictureBase64, - !string.IsNullOrEmpty(data.Alias) && !string.Equals(data.Alias, data.UID, StringComparison.Ordinal) ? _lightlessSupporter : string.Empty, - string.IsNullOrEmpty(profile.Description) ? _noUserDescription : profile.Description); + var tags = profile.Tags ?? _emptyTagSet; + var profileData = BuildProfileData(data, profile, tags); + var supporterImage = !string.IsNullOrEmpty(data.Alias) && !string.Equals(data.Alias, data.UID, StringComparison.Ordinal) + ? _lightlessSupporter + : string.Empty; + LightlessUserProfileData profileUserData = new( + IsFlagged: profile.Disabled, + IsNSFW: profile.IsNSFW ?? false, + Base64ProfilePicture: string.IsNullOrEmpty(profile.ProfilePictureBase64) ? _lightlessLogo : profile.ProfilePictureBase64, + Base64SupporterPicture: supporterImage, + Base64BannerPicture: string.IsNullOrEmpty(profile.BannerPictureBase64) ? _lightlessBanner : profile.BannerPictureBase64, + Description: string.IsNullOrEmpty(profile.Description) ? _noUserDescription : profile.Description, + Tags: tags); _logger.LogTrace("Replacing data in _lightlessUserProfiles for User {data}", data.AliasOrUID); if (profileUserData.IsNSFW && !_lightlessConfigService.Current.ProfilesAllowNsfw && !string.Equals(_apiController.UID, data.UID, StringComparison.Ordinal)) { _lightlessUserProfiles[data] = _nsfwProfileUserData; + _lightlessProfiles[data] = _nsfwProfileData; } else { _lightlessUserProfiles[data] = profileUserData; + _lightlessProfiles[data] = profileData; } } catch (Exception ex) @@ -146,6 +241,7 @@ public class LightlessProfileManager : MediatorSubscriberBase // if fails save DefaultProfileData to dict Logger.LogWarning(ex, "Failed to get Profile from service for user {user}", data); _lightlessUserProfiles[data] = _defaultProfileUserData; + _lightlessProfiles[data] = _defaultProfileData; } } @@ -161,14 +257,15 @@ public class LightlessProfileManager : MediatorSubscriberBase _logger.LogTrace("Inputting loading data in _lightlessGroupProfiles for Group {data}", data.AliasOrGID); _lightlessGroupProfiles[data] = _loadingProfileGroupData; var profile = await _apiController.GroupGetProfile(new API.Dto.Group.GroupDto(data)).ConfigureAwait(false); + var tags = profile.Tags ?? _emptyTagSet; LightlessGroupProfileData profileGroupData = new( + IsDisabled: profile.IsDisabled ?? false, + IsNsfw: profile.IsNsfw ?? false, Base64ProfilePicture: string.IsNullOrEmpty(profile.PictureBase64) ? _lightlessLogo : profile.PictureBase64, + Base64BannerPicture: string.IsNullOrEmpty(profile.BannerBase64) ? _lightlessBanner : profile.BannerBase64, Description: string.IsNullOrEmpty(profile.Description) ? _noGroupDescription : profile.Description, - Tags: profile.Tags ?? [], - profile.IsNsfw ?? false, - profile.IsDisabled ?? false - ); + Tags: tags); _logger.LogTrace("Replacing data in _lightlessGroupProfiles for Group {data}", data.AliasOrGID); if (profileGroupData.IsNsfw && !_lightlessConfigService.Current.ProfilesAllowNsfw) @@ -179,7 +276,6 @@ public class LightlessProfileManager : MediatorSubscriberBase { _lightlessGroupProfiles[data] = profileGroupData; } - _lightlessGroupProfiles[data] = profileGroupData; } catch (Exception ex) { @@ -188,4 +284,51 @@ public class LightlessProfileManager : MediatorSubscriberBase _lightlessGroupProfiles[data] = _defaultProfileGroupData; } } + + private LightlessProfileData BuildProfileData(UserData data, UserProfileDto profile, IReadOnlyList tags) + { + var supporterImage = !string.IsNullOrEmpty(data.Alias) && !string.Equals(data.Alias, data.UID, StringComparison.Ordinal) + ? _lightlessSupporter + : string.Empty; + var profileData = new LightlessProfileData( + IsFlagged: profile.Disabled, + IsNSFW: profile.IsNSFW ?? false, + Base64ProfilePicture: string.IsNullOrEmpty(profile.ProfilePictureBase64) ? _lightlessLogo : profile.ProfilePictureBase64, + Base64SupporterPicture: supporterImage, + Base64BannerPicture: string.IsNullOrEmpty(profile.BannerPictureBase64) ? _lightlessBanner : profile.BannerPictureBase64, + Description: string.IsNullOrEmpty(profile.Description) ? _noDescription : profile.Description, + Tags: tags); + + if (profileData.IsNSFW && !_lightlessConfigService.Current.ProfilesAllowNsfw && !string.Equals(_apiController.UID, data.UID, StringComparison.Ordinal)) + { + return _nsfwProfileData; + } + + return profileData; + } + + public async Task<(UserData User, LightlessProfileData ProfileData)?> GetLightfinderProfileAsync(string hashedCid) + { + if (string.IsNullOrWhiteSpace(hashedCid)) + return null; + + try + { + var profile = await _apiController.UserGetLightfinderProfile(hashedCid).ConfigureAwait(false); + if (profile == null) + return null; + + var userData = profile.User; + var profileTags = profile.Tags ?? _emptyTagSet; + var profileData = BuildProfileData(userData, profile, profileTags); + _lightlessProfiles[userData] = profileData; + + return (userData, profileData); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to get Lightfinder profile for CID {HashedCid}", hashedCid); + return null; + } + } } \ No newline at end of file diff --git a/LightlessSync/Services/LightlessUserProfileData.cs b/LightlessSync/Services/LightlessUserProfileData.cs index 3319043..b4ba383 100644 --- a/LightlessSync/Services/LightlessUserProfileData.cs +++ b/LightlessSync/Services/LightlessUserProfileData.cs @@ -1,7 +1,20 @@ -namespace LightlessSync.Services; +using System; +using System.Collections.Generic; -public record LightlessUserProfileData(bool IsFlagged, bool IsNSFW, string Base64ProfilePicture, string Base64SupporterPicture, string Description) +namespace LightlessSync.Services; + +public record LightlessUserProfileData( + bool IsFlagged, + bool IsNSFW, + string Base64ProfilePicture, + string Base64SupporterPicture, + string Base64BannerPicture, + string Description, + IReadOnlyList Tags) { - public Lazy ImageData { get; } = new Lazy(Convert.FromBase64String(Base64ProfilePicture)); - public Lazy SupporterImageData { get; } = new Lazy(string.IsNullOrEmpty(Base64SupporterPicture) ? [] : Convert.FromBase64String(Base64SupporterPicture)); + public Lazy ImageData { get; } = new(() => ConvertSafe(Base64ProfilePicture)); + public Lazy SupporterImageData { get; } = new(() => ConvertSafe(Base64SupporterPicture)); + public Lazy BannerImageData { get; } = new(() => ConvertSafe(Base64BannerPicture)); + + private static byte[] ConvertSafe(string value) => string.IsNullOrEmpty(value) ? Array.Empty() : Convert.FromBase64String(value); } diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 79434c2..ef31cec 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -6,9 +6,12 @@ using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services.ActorTracking; +using LightlessSync.Services.Chat; using LightlessSync.Services.Events; using LightlessSync.WebAPI.Files.Models; using System.Numerics; +using LightlessSync.UI.Models; namespace LightlessSync.Services.Mediator; @@ -20,12 +23,15 @@ public record OpenSettingsUiMessage : MessageBase; public record OpenLightfinderSettingsMessage : MessageBase; public record DalamudLoginMessage : MessageBase; public record DalamudLogoutMessage : MessageBase; +public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage; +public record ActorUntrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage; public record PriorityFrameworkUpdateMessage : SameThreadMessage; public record FrameworkUpdateMessage : SameThreadMessage; public record ClassJobChangedMessage(GameObjectHandler GameObjectHandler) : MessageBase; public record DelayedFrameworkUpdateMessage : SameThreadMessage; public record ZoneSwitchStartMessage : MessageBase; public record ZoneSwitchEndMessage : MessageBase; +public record WorldChangedMessage(ushort PreviousWorldId, ushort CurrentWorldId) : MessageBase; public record CutsceneStartMessage : MessageBase; public record GposeStartMessage : SameThreadMessage; public record GposeEndMessage : MessageBase; @@ -65,6 +71,7 @@ public record HubReconnectingMessage(Exception? Exception) : SameThreadMessage; public record HubReconnectedMessage(string? Arg) : SameThreadMessage; public record HubClosedMessage(Exception? Exception) : SameThreadMessage; public record ResumeScanMessage(string Source) : MessageBase; +public record FileCacheInitializedMessage : MessageBase; public record DownloadReadyMessage(Guid RequestId) : MessageBase; public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary DownloadStatus) : MessageBase; public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase; @@ -72,11 +79,18 @@ public record UiToggleMessage(Type UiType) : MessageBase; public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase; public record ClearProfileUserDataMessage(UserData? UserData = null) : MessageBase; public record ClearProfileGroupDataMessage(GroupData? GroupData = null) : MessageBase; -public record CyclePauseMessage(UserData UserData) : MessageBase; +public record CyclePauseMessage(Pair Pair) : MessageBase; public record PauseMessage(UserData UserData) : MessageBase; public record ProfilePopoutToggle(Pair? Pair) : MessageBase; public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase; public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase; +public record GroupProfileOpenStandaloneMessage(GroupFullInfoDto Group) : MessageBase; +public record OpenGroupProfileEditorMessage(GroupFullInfoDto Group) : MessageBase; +public record CloseGroupProfilePreviewMessage(GroupFullInfoDto Group) : MessageBase; +public record ActiveServerChangedMessage(string ServerUrl) : MessageBase; +public record OpenSelfProfilePreviewMessage(UserData User) : MessageBase; +public record CloseSelfProfilePreviewMessage(UserData User) : MessageBase; +public record OpenLightfinderProfileMessage(UserData User, LightlessProfileData ProfileData, string HashedCid) : MessageBase; public record RemoveWindowMessage(WindowMediatorSubscriberBase Window) : MessageBase; public record RefreshUiMessage : MessageBase; public record OpenBanUserPopupMessage(Pair PairToBan, GroupFullInfoDto GroupFullInfoDto) : MessageBase; @@ -85,6 +99,8 @@ public record OpenSyncshellAdminPanel(GroupFullInfoDto GroupInfo) : MessageBase; public record OpenPermissionWindow(Pair Pair) : MessageBase; public record DownloadLimitChangedMessage() : SameThreadMessage; public record PairProcessingLimitChangedMessage : SameThreadMessage; +public record PairDataChangedMessage : MessageBase; +public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase; public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase; public record TargetPairMessage(Pair Pair) : MessageBase; public record CombatStartMessage : MessageBase; @@ -112,5 +128,10 @@ public record PairRequestReceivedMessage(string HashedCid, string Message) : Mes public record PairRequestsUpdatedMessage : MessageBase; public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, string Status)> DownloadStatus, int QueueWaiting) : MessageBase; public record VisibilityChange : MessageBase; +public record ChatChannelsUpdated : MessageBase; +public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; +public record ChatChannelHistoryCleared(string ChannelKey) : MessageBase; +public record GroupCollectionChangedMessage : MessageBase; +public record OpenUserProfileMessage(UserData User) : MessageBase; #pragma warning restore S2094 #pragma warning restore MA0048 // File name must match type name \ No newline at end of file diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index 11af974..313eabe 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -7,9 +7,9 @@ using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using LightlessSync.LightlessConfiguration; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; +using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; @@ -30,7 +30,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber private readonly IClientState _clientState; private readonly DalamudUtilService _dalamudUtil; private readonly LightlessConfigService _configService; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; public LightlessMediator Mediator => _mediator; @@ -51,7 +51,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber private ImmutableHashSet _activeBroadcastingCids = []; - public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairManager pairManager) + public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairUiService pairUiService) { _logger = logger; _addonLifecycle = addonLifecycle; @@ -60,7 +60,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber _configService = configService; _mediator = mediator; _clientState = clientState; - _pairManager = pairManager; + _pairUiService = pairUiService; System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); } @@ -493,7 +493,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber int centrePos = (nameplateWidth - nodeWidth) / 2; int staticMargin = 24; int calcMargin = (int)(nameplateWidth * 0.08f); - + switch (config.LabelAlignment) { case LabelAlignment.Left: @@ -515,7 +515,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber positionX = 58 + config.LightfinderLabelOffsetX; alignment = AlignmentType.Bottom; } - + positionY += config.LightfinderLabelOffsetY; alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); @@ -533,7 +533,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pNode->EdgeColor.B = (byte)(edgeColor.Z * 255); pNode->EdgeColor.A = (byte)(edgeColor.W * 255); - + if(!config.LightfinderLabelUseIcon) { pNode->AlignmentType = AlignmentType.Bottom; @@ -551,7 +551,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pNode->CharSpacing = 1; pNode->TextFlags = config.LightfinderLabelUseIcon ? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize - : TextFlags.Edge | TextFlags.Glare; + : TextFlags.Edge | TextFlags.Glare; } } @@ -653,8 +653,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber var nameplateObject = GetNameplateObject(i); return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; } - - private HashSet VisibleUserIds => [.. _pairManager.GetOnlineUserPairs() + private HashSet VisibleUserIds + => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; diff --git a/LightlessSync/Services/NameplateService.cs b/LightlessSync/Services/NameplateService.cs index 8ccc362..84b6d64 100644 --- a/LightlessSync/Services/NameplateService.cs +++ b/LightlessSync/Services/NameplateService.cs @@ -4,9 +4,9 @@ using Dalamud.Game.Text.SeStringHandling; using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.LightlessConfiguration; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; +using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; @@ -17,20 +17,20 @@ public class NameplateService : DisposableMediatorSubscriberBase private readonly LightlessConfigService _configService; private readonly IClientState _clientState; private readonly INamePlateGui _namePlateGui; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; public NameplateService(ILogger logger, LightlessConfigService configService, INamePlateGui namePlateGui, IClientState clientState, - PairManager pairManager, + PairUiService pairUiService, LightlessMediator lightlessMediator) : base(logger, lightlessMediator) { _logger = logger; _configService = configService; _namePlateGui = namePlateGui; _clientState = clientState; - _pairManager = pairManager; + _pairUiService = pairUiService; _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; _namePlateGui.RequestRedraw(); @@ -42,7 +42,8 @@ public class NameplateService : DisposableMediatorSubscriberBase if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) return; - var visibleUsersIds = _pairManager.GetOnlineUserPairs() + var snapshot = _pairUiService.GetSnapshot(); + var visibleUsersIds = snapshot.PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId) .ToHashSet(); @@ -74,7 +75,7 @@ public class NameplateService : DisposableMediatorSubscriberBase bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0; bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId; bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm); - + if (shouldColorFcArea) { handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors); diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 8709710..cb1a607 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -4,9 +4,14 @@ using Dalamud.Interface.ImGuiNotification; using Dalamud.Plugin.Services; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; +using LightlessSync; +using LightlessSync.PlayerData.Factories; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.UI.Models; +using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using FFXIVClientStructs.FFXIV.Client.UI; @@ -24,6 +29,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private readonly IChatGui _chatGui; private readonly PairRequestService _pairRequestService; private readonly HashSet _shownPairRequestNotifications = new(); + private readonly PairUiService _pairUiService; + private readonly PairFactory _pairFactory; public NotificationService( ILogger logger, @@ -32,7 +39,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ INotificationManager notificationManager, IChatGui chatGui, LightlessMediator mediator, - PairRequestService pairRequestService) : base(logger, mediator) + PairRequestService pairRequestService, + PairUiService pairUiService, + PairFactory pairFactory) : base(logger, mediator) { _logger = logger; _configService = configService; @@ -40,6 +49,8 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ _notificationManager = notificationManager; _chatGui = chatGui; _pairRequestService = pairRequestService; + _pairUiService = pairUiService; + _pairFactory = pairFactory; } public Task StartAsync(CancellationToken cancellationToken) @@ -391,6 +402,17 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ _logger.LogWarning(ex, "Failed to play notification sound effect {SoundId}", soundEffectId); } } + private Pair? ResolvePair(UserData userData) + { + var snapshot = _pairUiService.GetSnapshot(); + if (snapshot.PairsByUid.TryGetValue(userData.UID, out var pair)) + { + return pair; + } + + var ident = new PairUniqueIdentifier(userData.UID); + return _pairFactory.Create(ident); + } private void HandleNotificationMessage(NotificationMessage msg) { @@ -659,7 +681,14 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ { try { - Mediator.Publish(new CyclePauseMessage(userData)); + var pair = ResolvePair(userData); + if (pair == null) + { + _logger.LogWarning("Cannot cycle pause {uid} because pair is missing", userData.UID); + throw new InvalidOperationException("Pair not available"); + } + + Mediator.Publish(new CyclePauseMessage(pair)); DismissNotification(notification); var displayName = GetUserDisplayName(userData, playerName); diff --git a/LightlessSync/Services/PairRequestService.cs b/LightlessSync/Services/PairRequestService.cs index 2531a3a..206fea3 100644 --- a/LightlessSync/Services/PairRequestService.cs +++ b/LightlessSync/Services/PairRequestService.cs @@ -1,6 +1,6 @@ using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; @@ -8,10 +8,11 @@ namespace LightlessSync.Services; public sealed class PairRequestService : DisposableMediatorSubscriberBase { private readonly DalamudUtilService _dalamudUtil; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly Lazy _apiController; private readonly Lock _syncRoot = new(); private readonly List _requests = []; + private readonly Dictionary _displayNameCache = new(StringComparer.Ordinal); private static readonly TimeSpan _expiration = TimeSpan.FromMinutes(5); @@ -19,12 +20,12 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase ILogger logger, LightlessMediator mediator, DalamudUtilService dalamudUtil, - PairManager pairManager, + PairUiService pairUiService, Lazy apiController) : base(logger, mediator) { _dalamudUtil = dalamudUtil; - _pairManager = pairManager; + _pairUiService = pairUiService; _apiController = apiController; Mediator.Subscribe(this, _ => @@ -96,6 +97,10 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase lock (_syncRoot) { removed = _requests.RemoveAll(r => string.Equals(r.HashedCid, hashedCid, StringComparison.Ordinal)) > 0; + if (removed) + { + _displayNameCache.Remove(hashedCid); + } } if (removed) @@ -129,6 +134,23 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase return string.Empty; } + if (TryGetCachedDisplayName(hashedCid, out var cached)) + { + return cached; + } + + var resolved = ResolveDisplayNameInternal(hashedCid); + if (!string.IsNullOrWhiteSpace(resolved)) + { + CacheDisplayName(hashedCid, resolved); + return resolved; + } + + return string.Empty; + } + + private string ResolveDisplayNameInternal(string hashedCid) + { var (name, address) = _dalamudUtil.FindPlayerByNameHash(hashedCid); if (!string.IsNullOrWhiteSpace(name)) { @@ -138,8 +160,9 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase : name; } - var pair = _pairManager - .GetOnlineUserPairs() + var snapshot = _pairUiService.GetSnapshot(); + var pair = snapshot.PairsByUid.Values + .Where(p => !string.IsNullOrEmpty(p.GetPlayerNameHash())) .FirstOrDefault(p => string.Equals(p.Ident, hashedCid, StringComparison.Ordinal)); if (pair != null) @@ -185,7 +208,21 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase } var now = DateTime.UtcNow; - return _requests.RemoveAll(r => now - r.ReceivedAt > _expiration) > 0; + var removedAny = false; + for (var i = _requests.Count - 1; i >= 0; i--) + { + var entry = _requests[i]; + if (now - entry.ReceivedAt <= _expiration) + { + continue; + } + + _displayNameCache.Remove(entry.HashedCid); + _requests.RemoveAt(i); + removedAny = true; + } + + return removedAny; } public void AcceptPairRequest(string hashedCid, string displayName) @@ -229,4 +266,32 @@ public sealed class PairRequestService : DisposableMediatorSubscriberBase private record struct PairRequestEntry(string HashedCid, string MessageTemplate, DateTime ReceivedAt); public readonly record struct PairRequestDisplay(string HashedCid, string DisplayName, string Message, DateTime ReceivedAt); + + private bool TryGetCachedDisplayName(string hashedCid, out string displayName) + { + lock (_syncRoot) + { + if (!string.IsNullOrWhiteSpace(hashedCid) && _displayNameCache.TryGetValue(hashedCid, out var cached)) + { + displayName = cached; + return true; + } + } + + displayName = string.Empty; + return false; + } + + private void CacheDisplayName(string hashedCid, string displayName) + { + if (string.IsNullOrWhiteSpace(hashedCid) || string.IsNullOrWhiteSpace(displayName) || string.Equals(hashedCid, displayName, StringComparison.Ordinal)) + { + return; + } + + lock (_syncRoot) + { + _displayNameCache[hashedCid] = displayName; + } + } } diff --git a/LightlessSync/Services/PlayerPerformanceService.cs b/LightlessSync/Services/PlayerPerformanceService.cs index 7db92e1..9382cf7 100644 --- a/LightlessSync/Services/PlayerPerformanceService.cs +++ b/LightlessSync/Services/PlayerPerformanceService.cs @@ -1,9 +1,13 @@ +using System; +using System.IO; using LightlessSync.API.Data; +using LightlessSync.API.Data.Extensions; using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; -using LightlessSync.PlayerData.Handlers; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; +using LightlessSync.Services.TextureCompression; using LightlessSync.UI; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; @@ -17,20 +21,22 @@ public class PlayerPerformanceService private readonly ILogger _logger; private readonly LightlessMediator _mediator; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly TextureDownscaleService _textureDownscaleService; private readonly Dictionary _warnedForPlayers = new(StringComparer.Ordinal); public PlayerPerformanceService(ILogger logger, LightlessMediator mediator, PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager, - XivDataAnalyzer xivDataAnalyzer) + XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService) { _logger = logger; _mediator = mediator; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; _xivDataAnalyzer = xivDataAnalyzer; + _textureDownscaleService = textureDownscaleService; } - public async Task CheckBothThresholds(PairHandler pairHandler, CharacterData charaData) + public async Task CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData) { var config = _playerPerformanceConfigService.Current; bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []); @@ -39,37 +45,37 @@ public class PlayerPerformanceService if (!notPausedAfterTris) return false; if (config.UIDsToIgnore - .Exists(uid => string.Equals(uid, pairHandler.Pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.Pair.UserData.UID, StringComparison.Ordinal))) + .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; - var vramUsage = pairHandler.Pair.LastAppliedApproximateVRAMBytes; - var triUsage = pairHandler.Pair.LastAppliedDataTris; + var vramUsage = pairHandler.LastAppliedApproximateVRAMBytes; + var triUsage = pairHandler.LastAppliedDataTris; - bool isPrefPerm = pairHandler.Pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky); + bool isPrefPerm = pairHandler.HasStickyPermissions; - bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000, + bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000L, triUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm); - bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024 * 1024, + bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024L * 1024L, vramUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm); - if (_warnedForPlayers.TryGetValue(pairHandler.Pair.UserData.UID, out bool hadWarning) && hadWarning) + if (_warnedForPlayers.TryGetValue(pairHandler.UserData.UID, out bool hadWarning) && hadWarning) { - _warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram; + _warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram; return true; } - _warnedForPlayers[pairHandler.Pair.UserData.UID] = exceedsTris || exceedsVram; + _warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram; if (exceedsVram) { - _mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, + _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeWarningThresholdMiB} MiB)"))); } if (exceedsTris) { - _mediator.Publish(new EventMessage(new Event(pairHandler.Pair.PlayerName, pairHandler.Pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, + _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds triangle threshold: ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)"))); } @@ -78,41 +84,40 @@ public class PlayerPerformanceService string warningText = string.Empty; if (exceedsTris && !exceedsVram) { - warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" + + warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" + $"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles"; } else if (!exceedsTris) { - warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" + + warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" + $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB"; } else { - warningText = $"Player {pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" + + warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" + $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles"; } _mediator.Publish(new PerformanceNotificationMessage( - $"{pairHandler.Pair.PlayerName} ({pairHandler.Pair.UserData.AliasOrUID}) exceeds performance threshold(s)", + $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds performance threshold(s)", warningText, - pairHandler.Pair.UserData, - pairHandler.Pair.IsPaused, - pairHandler.Pair.PlayerName)); + pairHandler.UserData, + pairHandler.IsPaused, + pairHandler.PlayerName)); } return true; } - public async Task CheckTriangleUsageThresholds(PairHandler pairHandler, CharacterData charaData) + public async Task CheckTriangleUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData) { var config = _playerPerformanceConfigService.Current; - var pair = pairHandler.Pair; long triUsage = 0; if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List? playerReplacements)) { - pair.LastAppliedDataTris = 0; + pairHandler.LastAppliedDataTris = 0; return true; } @@ -126,35 +131,35 @@ public class PlayerPerformanceService triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false); } - pair.LastAppliedDataTris = triUsage; + pairHandler.LastAppliedDataTris = triUsage; _logger.LogDebug("Calculated VRAM usage for {p}", pairHandler); // no warning of any kind on ignored pairs if (config.UIDsToIgnore - .Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal))) + .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; - bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky); + bool isPrefPerm = pairHandler.HasStickyPermissions; // now check auto pause - if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000, + if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000L, triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) { - var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" + + var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" + $"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles"; _mediator.Publish(new PerformanceNotificationMessage( - $"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused", + $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused", message, - pair.UserData, + pairHandler.UserData, true, - pair.PlayerName)); + pairHandler.PlayerName)); - _mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, + _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)"))); - _mediator.Publish(new PauseMessage(pair.UserData)); + _mediator.Publish(new PauseMessage(pairHandler.UserData)); return false; } @@ -162,16 +167,18 @@ public class PlayerPerformanceService return true; } - public bool ComputeAndAutoPauseOnVRAMUsageThresholds(PairHandler pairHandler, CharacterData charaData, List toDownloadFiles) + public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List toDownloadFiles) { var config = _playerPerformanceConfigService.Current; - var pair = pairHandler.Pair; + bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions; long vramUsage = 0; + long effectiveVramUsage = 0; if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List? playerReplacements)) { - pair.LastAppliedApproximateVRAMBytes = 0; + pairHandler.LastAppliedApproximateVRAMBytes = 0; + pairHandler.LastAppliedApproximateEffectiveVRAMBytes = 0; return true; } @@ -183,11 +190,13 @@ public class PlayerPerformanceService foreach (var hash in moddedTextureHashes) { long fileSize = 0; + long effectiveSize = 0; var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase)); if (download != null) { fileSize = download.TotalRaw; + effectiveSize = fileSize; } else { @@ -201,39 +210,63 @@ public class PlayerPerformanceService } fileSize = fileEntry.Size.Value; + effectiveSize = fileSize; + + if (!skipDownscale) + { + var preferredPath = _textureDownscaleService.GetPreferredPath(hash, fileEntry.ResolvedFilepath); + if (!string.IsNullOrEmpty(preferredPath) && File.Exists(preferredPath)) + { + try + { + effectiveSize = new FileInfo(preferredPath).Length; + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to read size for preferred texture path {Path}", preferredPath); + effectiveSize = fileSize; + } + } + else + { + effectiveSize = fileSize; + } + } } vramUsage += fileSize; + effectiveVramUsage += effectiveSize; } - pair.LastAppliedApproximateVRAMBytes = vramUsage; + pairHandler.LastAppliedApproximateVRAMBytes = vramUsage; + pairHandler.LastAppliedApproximateEffectiveVRAMBytes = effectiveVramUsage; _logger.LogDebug("Calculated VRAM usage for {p}", pairHandler); // no warning of any kind on ignored pairs if (config.UIDsToIgnore - .Exists(uid => string.Equals(uid, pair.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pair.UserData.UID, StringComparison.Ordinal))) + .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; - bool isPrefPerm = pair.UserPair.OwnPermissions.HasFlag(API.Data.Enum.UserPermissions.Sticky); + bool isPrefPerm = pairHandler.HasStickyPermissions; // now check auto pause - if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024 * 1024, + if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024L * 1024L, vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) { - var message = $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" + + var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" + $"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB"; - + _mediator.Publish(new PerformanceNotificationMessage( - $"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically paused", + $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused", message, - pair.UserData, + pairHandler.UserData, true, - pair.PlayerName)); + pairHandler.PlayerName)); - _mediator.Publish(new PauseMessage(pair.UserData)); + _mediator.Publish(new PauseMessage(pairHandler.UserData)); - _mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, + _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds VRAM threshold: automatically paused ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB} MiB)"))); return false; diff --git a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs index 388ac87..5cb3e15 100644 --- a/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs +++ b/LightlessSync/Services/ServerConfiguration/ServerConfigurationManager.cs @@ -252,9 +252,16 @@ public class ServerConfigurationManager public void SelectServer(int idx) { + var previousIndex = _configService.Current.CurrentServer; _configService.Current.CurrentServer = idx; CurrentServer!.FullPause = false; Save(); + + if (previousIndex != idx) + { + var serverUrl = CurrentServer.ServerUri; + _lightlessMediator.Publish(new ActiveServerChangedMessage(serverUrl)); + } } internal void AddCurrentCharacterToServer(int serverSelectionIndex = -1) diff --git a/LightlessSync/Services/TextureCompression/TexFileHelper.cs b/LightlessSync/Services/TextureCompression/TexFileHelper.cs new file mode 100644 index 0000000..b5e2ab8 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TexFileHelper.cs @@ -0,0 +1,282 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Lumina.Data.Files; +using OtterTex; + +namespace LightlessSync.Services.TextureCompression; + +// base taken from penumbra mostly +internal static class TexFileHelper +{ + private const int HeaderSize = 80; + private const int MaxMipLevels = 13; + + public static ScratchImage Load(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + return Load(stream); + } + + public static ScratchImage Load(Stream stream) + { + using var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, leaveOpen: true); + var header = ReadHeader(reader); + var meta = CreateMeta(header); + meta.MipLevels = ComputeMipCount(stream.Length, header, meta); + if (meta.MipLevels == 0) + { + throw new InvalidOperationException("TEX file does not contain a valid mip chain."); + } + + var scratch = ScratchImage.Initialize(meta); + ReadPixelData(reader, scratch); + return scratch; + } + + public static void Save(string path, ScratchImage image) + { + var header = BuildHeader(image); + if (header.Format == TexFile.TextureFormat.Unknown) + { + throw new InvalidOperationException($"Unable to export TEX file with unsupported format {image.Meta.Format}."); + } + + var mode = File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew; + using var stream = new FileStream(path, mode, FileAccess.Write, FileShare.Read); + using var writer = new BinaryWriter(stream); + WriteHeader(writer, header); + writer.Write(image.Pixels); + GC.KeepAlive(image); + } + + private static TexFile.TexHeader ReadHeader(BinaryReader reader) + { + Span buffer = stackalloc byte[HeaderSize]; + var read = reader.Read(buffer); + if (read != HeaderSize) + { + throw new EndOfStreamException($"Incomplete TEX header: expected {HeaderSize} bytes, read {read} bytes."); + } + + return MemoryMarshal.Read(buffer); + } + + private static TexMeta CreateMeta(in TexFile.TexHeader header) + { + var meta = new TexMeta + { + Width = header.Width, + Height = header.Height, + Depth = Math.Max(header.Depth, (ushort)1), + ArraySize = 1, + MipLevels = header.MipCount, + Format = header.Format.ToDxgi(), + Dimension = header.Type.ToDimension(), + MiscFlags = header.Type.HasFlag(TexFile.Attribute.TextureTypeCube) ? D3DResourceMiscFlags.TextureCube : 0, + MiscFlags2 = 0, + }; + + if (meta.Format == DXGIFormat.Unknown) + { + throw new InvalidOperationException($"TEX format {header.Format} cannot be mapped to DXGI."); + } + + if (meta.Dimension == TexDimension.Unknown) + { + throw new InvalidOperationException($"Unrecognised TEX dimension attribute {header.Type}."); + } + + return meta; + } + + private static unsafe int ComputeMipCount(long totalLength, in TexFile.TexHeader header, in TexMeta meta) + { + var width = Math.Max(meta.Width, 1); + var height = Math.Max(meta.Height, 1); + var minSide = meta.Format.IsCompressed() ? 4 : 1; + var bitsPerPixel = meta.Format.BitsPerPixel(); + + var expectedOffset = HeaderSize; + var remaining = totalLength - HeaderSize; + + for (var level = 0; level < MaxMipLevels; level++) + { + var declaredOffset = header.OffsetToSurface[level]; + if (declaredOffset == 0) + { + return level; + } + + if (declaredOffset != expectedOffset || remaining <= 0) + { + return level; + } + + var mipSize = (int)((long)width * height * bitsPerPixel / 8); + if (mipSize > remaining) + { + return level; + } + + expectedOffset += mipSize; + remaining -= mipSize; + + if (width <= minSide && height <= minSide) + { + return level + 1; + } + + width = Math.Max(width / 2, minSide); + height = Math.Max(height / 2, minSide); + } + + return MaxMipLevels; + } + + private static unsafe void ReadPixelData(BinaryReader reader, ScratchImage image) + { + fixed (byte* destination = image.Pixels) + { + var span = new Span(destination, image.Pixels.Length); + var read = reader.Read(span); + if (read < span.Length) + { + throw new InvalidDataException($"TEX pixel buffer is truncated (read {read} of {span.Length} bytes)."); + } + } + } + + private static TexFile.TexHeader BuildHeader(ScratchImage image) + { + var meta = image.Meta; + var header = new TexFile.TexHeader + { + Width = (ushort)meta.Width, + Height = (ushort)meta.Height, + Depth = (ushort)Math.Max(meta.Depth, 1), + MipCount = (byte)Math.Min(meta.MipLevels, MaxMipLevels), + Format = meta.Format.ToTex(), + Type = meta.Dimension switch + { + _ when meta.IsCubeMap => TexFile.Attribute.TextureTypeCube, + TexDimension.Tex1D => TexFile.Attribute.TextureType1D, + TexDimension.Tex2D => TexFile.Attribute.TextureType2D, + TexDimension.Tex3D => TexFile.Attribute.TextureType3D, + _ => 0, + }, + }; + + PopulateOffsets(ref header, image); + return header; + } + + private static unsafe void PopulateOffsets(ref TexFile.TexHeader header, ScratchImage image) + { + var index = 0; + fixed (byte* basePtr = image.Pixels) + { + foreach (var mip in image.Images) + { + if (index >= MaxMipLevels) + { + break; + } + + var byteOffset = (byte*)mip.Pixels - basePtr; + header.OffsetToSurface[index++] = HeaderSize + (uint)byteOffset; + } + } + + while (index < MaxMipLevels) + { + header.OffsetToSurface[index++] = 0; + } + + header.LodOffset[0] = 0; + header.LodOffset[1] = (byte)Math.Min(header.MipCount - 1, 1); + header.LodOffset[2] = (byte)Math.Min(header.MipCount - 1, 2); + } + + private static unsafe void WriteHeader(BinaryWriter writer, in TexFile.TexHeader header) + { + writer.Write((uint)header.Type); + writer.Write((uint)header.Format); + writer.Write(header.Width); + writer.Write(header.Height); + writer.Write(header.Depth); + writer.Write((byte)(header.MipCount | (header.MipUnknownFlag ? 0x80 : 0))); + writer.Write(header.ArraySize); + writer.Write(header.LodOffset[0]); + writer.Write(header.LodOffset[1]); + writer.Write(header.LodOffset[2]); + for (var i = 0; i < MaxMipLevels; i++) + { + writer.Write(header.OffsetToSurface[i]); + } + } + + private static TexDimension ToDimension(this TexFile.Attribute attribute) + => (attribute & TexFile.Attribute.TextureTypeMask) switch + { + TexFile.Attribute.TextureType1D => TexDimension.Tex1D, + TexFile.Attribute.TextureType2D => TexDimension.Tex2D, + TexFile.Attribute.TextureType3D => TexDimension.Tex3D, + _ => TexDimension.Unknown, + }; + + private static DXGIFormat ToDxgi(this TexFile.TextureFormat format) + => format switch + { + TexFile.TextureFormat.L8 => DXGIFormat.R8UNorm, + TexFile.TextureFormat.A8 => DXGIFormat.A8UNorm, + TexFile.TextureFormat.B4G4R4A4 => DXGIFormat.B4G4R4A4UNorm, + TexFile.TextureFormat.B5G5R5A1 => DXGIFormat.B5G5R5A1UNorm, + TexFile.TextureFormat.B8G8R8A8 => DXGIFormat.B8G8R8A8UNorm, + TexFile.TextureFormat.B8G8R8X8 => DXGIFormat.B8G8R8X8UNorm, + TexFile.TextureFormat.R32F => DXGIFormat.R32Float, + TexFile.TextureFormat.R16G16F => DXGIFormat.R16G16Float, + TexFile.TextureFormat.R32G32F => DXGIFormat.R32G32Float, + TexFile.TextureFormat.R16G16B16A16F => DXGIFormat.R16G16B16A16Float, + TexFile.TextureFormat.R32G32B32A32F => DXGIFormat.R32G32B32A32Float, + TexFile.TextureFormat.BC1 => DXGIFormat.BC1UNorm, + TexFile.TextureFormat.BC2 => DXGIFormat.BC2UNorm, + TexFile.TextureFormat.BC3 => DXGIFormat.BC3UNorm, + (TexFile.TextureFormat)0x6120 => DXGIFormat.BC4UNorm, + TexFile.TextureFormat.BC5 => DXGIFormat.BC5UNorm, + (TexFile.TextureFormat)0x6330 => DXGIFormat.BC6HSF16, + TexFile.TextureFormat.BC7 => DXGIFormat.BC7UNorm, + TexFile.TextureFormat.D16 => DXGIFormat.R16G16B16A16Typeless, + TexFile.TextureFormat.D24S8 => DXGIFormat.R24G8Typeless, + TexFile.TextureFormat.Shadow16 => DXGIFormat.R16Typeless, + TexFile.TextureFormat.Shadow24 => DXGIFormat.R24G8Typeless, + _ => DXGIFormat.Unknown, + }; + + private static TexFile.TextureFormat ToTex(this DXGIFormat format) + => format switch + { + DXGIFormat.R8UNorm => TexFile.TextureFormat.L8, + DXGIFormat.A8UNorm => TexFile.TextureFormat.A8, + DXGIFormat.B4G4R4A4UNorm => TexFile.TextureFormat.B4G4R4A4, + DXGIFormat.B5G5R5A1UNorm => TexFile.TextureFormat.B5G5R5A1, + DXGIFormat.B8G8R8A8UNorm => TexFile.TextureFormat.B8G8R8A8, + DXGIFormat.B8G8R8X8UNorm => TexFile.TextureFormat.B8G8R8X8, + DXGIFormat.R32Float => TexFile.TextureFormat.R32F, + DXGIFormat.R16G16Float => TexFile.TextureFormat.R16G16F, + DXGIFormat.R32G32Float => TexFile.TextureFormat.R32G32F, + DXGIFormat.R16G16B16A16Float => TexFile.TextureFormat.R16G16B16A16F, + DXGIFormat.R32G32B32A32Float => TexFile.TextureFormat.R32G32B32A32F, + DXGIFormat.BC1UNorm => TexFile.TextureFormat.BC1, + DXGIFormat.BC2UNorm => TexFile.TextureFormat.BC2, + DXGIFormat.BC3UNorm => TexFile.TextureFormat.BC3, + DXGIFormat.BC4UNorm => (TexFile.TextureFormat)0x6120, + DXGIFormat.BC5UNorm => TexFile.TextureFormat.BC5, + DXGIFormat.BC6HSF16 => (TexFile.TextureFormat)0x6330, + DXGIFormat.BC7UNorm => TexFile.TextureFormat.BC7, + DXGIFormat.R16G16B16A16Typeless => TexFile.TextureFormat.D16, + DXGIFormat.R24G8Typeless => TexFile.TextureFormat.D24S8, + DXGIFormat.R16Typeless => TexFile.TextureFormat.Shadow16, + _ => TexFile.TextureFormat.Unknown, + }; +} diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs new file mode 100644 index 0000000..81e10c5 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using Penumbra.Api.Enums; + +namespace LightlessSync.Services.TextureCompression; + +internal static class TextureCompressionCapabilities +{ + private static readonly ImmutableDictionary TexTargets = + new Dictionary + { + [TextureCompressionTarget.BC7] = TextureType.Bc7Tex, + [TextureCompressionTarget.BC3] = TextureType.Bc3Tex, + }.ToImmutableDictionary(); + + private static readonly ImmutableDictionary DdsTargets = + new Dictionary + { + [TextureCompressionTarget.BC7] = TextureType.Bc7Dds, + [TextureCompressionTarget.BC3] = TextureType.Bc3Dds, + }.ToImmutableDictionary(); + + private static readonly TextureCompressionTarget[] SelectableTargetsCache = TexTargets + .Select(kvp => kvp.Key) + .OrderBy(t => t) + .ToArray(); + + private static readonly HashSet SelectableTargetSet = SelectableTargetsCache.ToHashSet(); + + public static IReadOnlyList SelectableTargets => SelectableTargetsCache; + + public static TextureCompressionTarget DefaultTarget => TextureCompressionTarget.BC7; + + public static bool IsSelectable(TextureCompressionTarget target) => SelectableTargetSet.Contains(target); + + public static TextureCompressionTarget Normalize(TextureCompressionTarget? desired) + { + if (desired.HasValue && IsSelectable(desired.Value)) + { + return desired.Value; + } + + return DefaultTarget; + } + + public static bool TryGetPenumbraTarget(TextureCompressionTarget target, string? outputPath, out TextureType textureType) + { + if (!string.IsNullOrWhiteSpace(outputPath) && + string.Equals(Path.GetExtension(outputPath), ".dds", StringComparison.OrdinalIgnoreCase)) + { + return DdsTargets.TryGetValue(target, out textureType); + } + + return TexTargets.TryGetValue(target, out textureType); + } +} diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs b/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs new file mode 100644 index 0000000..0877d55 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace LightlessSync.Services.TextureCompression; + +public sealed record TextureCompressionRequest( + string PrimaryFilePath, + IReadOnlyList DuplicateFilePaths, + TextureCompressionTarget Target); diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs new file mode 100644 index 0000000..2d4a1d2 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using LightlessSync.Interop.Ipc; +using LightlessSync.FileCache; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Enums; + +namespace LightlessSync.Services.TextureCompression; + +public sealed class TextureCompressionService +{ + private readonly ILogger _logger; + private readonly IpcManager _ipcManager; + private readonly FileCacheManager _fileCacheManager; + + public IReadOnlyList SelectableTargets => TextureCompressionCapabilities.SelectableTargets; + public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget; + + public TextureCompressionService( + ILogger logger, + IpcManager ipcManager, + FileCacheManager fileCacheManager) + { + _logger = logger; + _ipcManager = ipcManager; + _fileCacheManager = fileCacheManager; + } + + public async Task ConvertTexturesAsync( + IReadOnlyList requests, + IProgress? progress, + CancellationToken token) + { + if (requests.Count == 0) + { + return; + } + + var total = requests.Count; + var completed = 0; + + foreach (var request in requests) + { + token.ThrowIfCancellationRequested(); + + if (!TextureCompressionCapabilities.TryGetPenumbraTarget(request.Target, request.PrimaryFilePath, out var textureType)) + { + _logger.LogWarning("Unsupported compression target {Target} requested.", request.Target); + completed++; + continue; + } + + await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false); + + completed++; + } + } + + public bool IsTargetSelectable(TextureCompressionTarget target) => TextureCompressionCapabilities.IsSelectable(target); + + public TextureCompressionTarget NormalizeTarget(TextureCompressionTarget? desired) => + TextureCompressionCapabilities.Normalize(desired); + + private async Task RunPenumbraConversionAsync( + TextureCompressionRequest request, + TextureType targetType, + int total, + int completedBefore, + IProgress? progress, + CancellationToken token) + { + var primaryPath = request.PrimaryFilePath; + var displayJob = new TextureConversionJob( + primaryPath, + primaryPath, + targetType, + IncludeMipMaps: true, + request.DuplicateFilePaths); + + var backupPath = CreateBackupCopy(primaryPath); + var conversionJob = displayJob with { InputFile = backupPath }; + + progress?.Report(new TextureConversionProgress(completedBefore, total, displayJob)); + + try + { + WaitForAccess(primaryPath); + await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false); + + if (!IsValidConversionResult(displayJob.OutputFile)) + { + throw new InvalidOperationException($"Penumbra conversion produced no output for {displayJob.OutputFile}."); + } + + UpdateFileCache(displayJob); + + progress?.Report(new TextureConversionProgress(completedBefore + 1, total, displayJob)); + } + catch (Exception ex) + { + RestoreFromBackup(backupPath, displayJob.OutputFile, displayJob.DuplicateTargets, ex); + throw; + } + finally + { + CleanupBackup(backupPath); + } + } + + private void UpdateFileCache(TextureConversionJob job) + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase) + { + job.OutputFile + }; + + if (job.DuplicateTargets is { Count: > 0 }) + { + foreach (var duplicate in job.DuplicateTargets) + { + paths.Add(duplicate); + } + } + + if (paths.Count == 0) + { + return; + } + + var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray()); + foreach (var path in paths) + { + if (!cacheEntries.TryGetValue(path, out var entry) || entry is null) + { + entry = _fileCacheManager.CreateFileEntry(path); + if (entry is null) + { + _logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path); + continue; + } + } + + try + { + _fileCacheManager.UpdateHashedFile(entry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path); + } + } + } + + private static readonly string WorkingDirectory = + Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression"); + + private static string CreateBackupCopy(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"Cannot back up missing texture file {filePath}.", filePath); + } + + Directory.CreateDirectory(WorkingDirectory); + + var extension = Path.GetExtension(filePath); + if (string.IsNullOrEmpty(extension)) + { + extension = ".tmp"; + } + + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); + var backupName = $"{fileNameWithoutExtension}.backup.{Guid.NewGuid():N}{extension}"; + var backupPath = Path.Combine(WorkingDirectory, backupName); + + WaitForAccess(filePath); + + File.Copy(filePath, backupPath, overwrite: false); + + return backupPath; + } + + private const int MaxAccessRetries = 10; + private static readonly TimeSpan AccessRetryDelay = TimeSpan.FromMilliseconds(200); + + private static void WaitForAccess(string filePath) + { + if (!File.Exists(filePath)) + { + return; + } + + try + { + File.SetAttributes(filePath, FileAttributes.Normal); + } + catch + { + // ignore attribute changes here + } + + Exception? lastException = null; + for (var attempt = 0; attempt < MaxAccessRetries; attempt++) + { + try + { + using var stream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.None); + return; + } + catch (IOException ex) when (IsSharingViolation(ex)) + { + lastException = ex; + } + + Thread.Sleep(AccessRetryDelay); + } + + if (lastException != null) + { + throw lastException; + } + } + + private static bool IsSharingViolation(IOException ex) => + ex.HResult == unchecked((int)0x80070020); + + private void RestoreFromBackup( + string backupPath, + string destinationPath, + IReadOnlyList? duplicateTargets, + Exception reason) + { + if (string.IsNullOrEmpty(backupPath)) + { + _logger.LogWarning(reason, "Conversion failed for {File}, but no backup was available to restore.", destinationPath); + return; + } + + if (!File.Exists(backupPath)) + { + _logger.LogWarning(reason, "Conversion failed for {File}, but backup path {Backup} no longer exists.", destinationPath, backupPath); + return; + } + + try + { + TryReplaceFile(backupPath, destinationPath); + } + catch (Exception restoreEx) + { + _logger.LogError(restoreEx, "Failed to restore texture {File} after conversion failure.", destinationPath); + return; + } + + if (duplicateTargets is { Count: > 0 }) + { + foreach (var duplicate in duplicateTargets) + { + if (string.Equals(destinationPath, duplicate, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + File.Copy(destinationPath, duplicate, overwrite: true); + } + catch (Exception duplicateEx) + { + _logger.LogDebug(duplicateEx, "Failed to restore duplicate {Duplicate} after conversion failure.", duplicate); + } + } + } + + _logger.LogWarning(reason, "Restored original texture {File} after conversion failure.", destinationPath); + } + + private static void TryReplaceFile(string sourcePath, string destinationPath) + { + WaitForAccess(destinationPath); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + File.Copy(sourcePath, destinationPath, overwrite: true); + } + + private static void CleanupBackup(string backupPath) + { + if (string.IsNullOrEmpty(backupPath)) + { + return; + } + + try + { + if (File.Exists(backupPath)) + { + File.Delete(backupPath); + } + } + catch + { + // avoid killing successful conversions on cleanup failure + } + } + + private static bool IsValidConversionResult(string path) + { + try + { + var fileInfo = new FileInfo(path); + return fileInfo.Exists && fileInfo.Length > 0; + } + catch + { + return false; + } + } +} diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionTarget.cs b/LightlessSync/Services/TextureCompression/TextureCompressionTarget.cs new file mode 100644 index 0000000..0928da4 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureCompressionTarget.cs @@ -0,0 +1,10 @@ +namespace LightlessSync.Services.TextureCompression; + +public enum TextureCompressionTarget +{ + BC1, + BC3, + BC4, + BC5, + BC7 +} diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs new file mode 100644 index 0000000..e5ead9d --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -0,0 +1,955 @@ +using System; +using System.Collections.Concurrent; +using System.Buffers.Binary; +using System.Globalization; +using System.Numerics; +using System.IO; +using OtterTex; +using OtterImage = OtterTex.Image; +using LightlessSync.LightlessConfiguration; +using LightlessSync.FileCache; +using Microsoft.Extensions.Logging; +using Lumina.Data.Files; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +/* + * Index upscaler code (converted/reversed for downscaling purposes) provided by Ny + * OtterTex made by Ottermandias + * thank you!! +*/ + +namespace LightlessSync.Services.TextureCompression; + +public sealed class TextureDownscaleService +{ + private const int DefaultTargetMaxDimension = 2048; + private const int MaxSupportedTargetDimension = 8192; + private const int BlockMultiple = 4; + + private readonly ILogger _logger; + private readonly LightlessConfigService _configService; + private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly FileCacheManager _fileCacheManager; + + private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _downscaledPaths = new(StringComparer.OrdinalIgnoreCase); + private static readonly IReadOnlyDictionary BlockCompressedFormatMap = + new Dictionary + { + [70] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_TYPELESS + [71] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM + [72] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM_SRGB + + [73] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_TYPELESS + [74] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM + [75] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM_SRGB + [76] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_TYPELESS + [77] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM + [78] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM_SRGB + + [79] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_TYPELESS + [80] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_UNORM + [81] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_SNORM + + [82] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_TYPELESS + [83] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_UNORM + [84] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_SNORM + + [94] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_TYPELESS (treated as BC7 for block detection) + [95] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_UF16 + [96] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_SF16 + [97] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_TYPELESS + [98] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM + [99] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM_SRGB + }; + + public TextureDownscaleService( + ILogger logger, + LightlessConfigService configService, + PlayerPerformanceConfigService playerPerformanceConfigService, + FileCacheManager fileCacheManager) + { + _logger = logger; + _configService = configService; + _playerPerformanceConfigService = playerPerformanceConfigService; + _fileCacheManager = fileCacheManager; + } + + public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) + { + if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; + if (_activeJobs.ContainsKey(hash)) return; + + _activeJobs[hash] = Task.Run(() => DownscaleInternalAsync(hash, filePath, mapKind), CancellationToken.None); + } + + public string GetPreferredPath(string hash, string originalPath) + { + if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing)) + { + return existing; + } + + var resolved = GetExistingDownscaledPath(hash); + if (!string.IsNullOrEmpty(resolved)) + { + _downscaledPaths[hash] = resolved; + return resolved; + } + + return originalPath; + } + + private async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind) + { + TexHeaderInfo? headerInfo = null; + string? destination = null; + int targetMaxDimension = 0; + bool onlyDownscaleUncompressed = false; + bool? isIndexTexture = null; + + try + { + if (!File.Exists(sourcePath)) + { + _logger.LogWarning("Cannot downscale texture {Hash}; source path missing: {Path}", hash, sourcePath); + return; + } + + headerInfo = TryReadTexHeader(sourcePath, out var header) + ? header + : (TexHeaderInfo?)null; + var performanceConfig = _playerPerformanceConfigService.Current; + targetMaxDimension = ResolveTargetMaxDimension(); + onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures; + + destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex"); + if (File.Exists(destination)) + { + RegisterDownscaledTexture(hash, sourcePath, destination); + return; + } + + var indexTexture = IsIndexMap(mapKind); + isIndexTexture = indexTexture; + if (!indexTexture) + { + if (performanceConfig.EnableNonIndexTextureMipTrim + && await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false)) + { + return; + } + + if (!performanceConfig.EnableNonIndexTextureMipTrim) + { + _logger.LogTrace("Skipping mip trim for non-index texture {Hash}; feature disabled.", hash); + } + + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash); + return; + } + + if (!performanceConfig.EnableIndexTextureDownscale) + { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash); + return; + } + + if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format)) + { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format); + return; + } + + using var sourceScratch = TexFileHelper.Load(sourcePath); + using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); + + var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8; + var width = rgbaInfo.Meta.Width; + var height = rgbaInfo.Meta.Height; + var requiredLength = width * height * bytesPerPixel; + + var rgbaPixels = rgbaScratch.Pixels[..requiredLength].ToArray(); + using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData(rgbaPixels, width, height); + + var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension); + if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height) + { + return; + } + + using var resized = ReduceIndexTexture(originalImage, targetSize.width, targetSize.height); + + var resizedPixels = new byte[targetSize.width * targetSize.height * 4]; + resized.CopyPixelDataTo(resizedPixels); + + using var resizedScratch = ScratchImage.FromRGBA(resizedPixels, targetSize.width, targetSize.height, out var creationInfo).ThrowIfError(creationInfo); + using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); + + TexFileHelper.Save(destination, finalScratch); + RegisterDownscaledTexture(hash, sourcePath, destination); + } + catch (Exception ex) + { + TryDelete(destination); + _logger.LogWarning( + ex, + "Texture downscale failed for {Hash} ({MapKind}) from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, IsIndex={IsIndexTexture}, HeaderFormat={HeaderFormat}", + hash, + mapKind, + sourcePath, + destination ?? "", + targetMaxDimension, + onlyDownscaleUncompressed, + isIndexTexture, + headerInfo?.Format); + } + finally + { + _activeJobs.TryRemove(hash, out _); + } + } + + private static (int width, int height) CalculateTargetSize(int width, int height, int targetMaxDimension) + { + var resultWidth = width; + var resultHeight = height; + + while (Math.Max(resultWidth, resultHeight) > targetMaxDimension) + { + resultWidth = Math.Max(BlockMultiple, resultWidth / 2); + resultHeight = Math.Max(BlockMultiple, resultHeight / 2); + } + + return (resultWidth, resultHeight); + } + + private static bool IsIndexMap(TextureMapKind kind) + => kind is TextureMapKind.Mask + or TextureMapKind.Index + or TextureMapKind.Ui; + + private Task TryDropTopMipAsync( + string hash, + string sourcePath, + string destination, + int targetMaxDimension, + bool onlyDownscaleUncompressed, + TexHeaderInfo? headerInfo = null) + { + TexHeaderInfo? header = headerInfo; + int dropCount = -1; + int originalWidth = 0; + int originalHeight = 0; + int originalMipLevels = 0; + + try + { + if (!File.Exists(sourcePath)) + { + _logger.LogWarning("Cannot trim mip levels for texture {Hash}; source path missing: {Path}", hash, sourcePath); + return Task.FromResult(false); + } + + if (header is null && TryReadTexHeader(sourcePath, out var discoveredHeader)) + { + header = discoveredHeader; + } + + if (header is TexHeaderInfo info) + { + if (onlyDownscaleUncompressed && IsBlockCompressedFormat(info.Format)) + { + _logger.LogTrace("Skipping mip trim for texture {Hash}; block compressed format {Format}.", hash, info.Format); + return Task.FromResult(false); + } + + if (info.MipLevels <= 1) + { + return Task.FromResult(false); + } + + var headerDepth = info.Depth == 0 ? 1 : info.Depth; + if (!ShouldTrimDimensions(info.Width, info.Height, headerDepth, targetMaxDimension)) + { + return Task.FromResult(false); + } + } + + using var original = TexFileHelper.Load(sourcePath); + var meta = original.Meta; + originalWidth = meta.Width; + originalHeight = meta.Height; + originalMipLevels = meta.MipLevels; + if (meta.MipLevels <= 1) + { + return Task.FromResult(false); + } + + if (!ShouldTrim(meta, targetMaxDimension)) + { + return Task.FromResult(false); + } + + var targetSize = CalculateTargetSize(meta.Width, meta.Height, targetMaxDimension); + dropCount = CalculateDropCount(meta, targetSize.width, targetSize.height); + if (dropCount <= 0) + { + return Task.FromResult(false); + } + + using var trimmed = TrimMipChain(original, dropCount); + TexFileHelper.Save(destination, trimmed); + RegisterDownscaledTexture(hash, sourcePath, destination); + _logger.LogDebug("Trimmed {DropCount} top mip level(s) for texture {Hash} -> {Path}", dropCount, hash, destination); + return Task.FromResult(true); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to trim mips for texture {Hash} from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, HeaderFormat={HeaderFormat}, OriginalSize={OriginalWidth}x{OriginalHeight}, OriginalMipLevels={OriginalMipLevels}, DropAttempt={DropCount}", + hash, + sourcePath, + destination, + targetMaxDimension, + onlyDownscaleUncompressed, + header?.Format, + originalWidth, + originalHeight, + originalMipLevels, + dropCount); + TryDelete(destination); + return Task.FromResult(false); + } + } + + private static int CalculateDropCount(in TexMeta meta, int targetWidth, int targetHeight) + { + var drop = 0; + var width = meta.Width; + var height = meta.Height; + + while ((width > targetWidth || height > targetHeight) && drop + 1 < meta.MipLevels) + { + drop++; + width = ReduceDimension(width); + height = ReduceDimension(height); + } + + return drop; + } + + private static ScratchImage TrimMipChain(ScratchImage source, int dropCount) + { + var meta = source.Meta; + var newMeta = meta; + newMeta.MipLevels = meta.MipLevels - dropCount; + newMeta.Width = ReduceDimension(meta.Width, dropCount); + newMeta.Height = ReduceDimension(meta.Height, dropCount); + if (meta.Dimension == TexDimension.Tex3D) + { + newMeta.Depth = ReduceDimension(meta.Depth, dropCount); + } + + var result = ScratchImage.Initialize(newMeta); + CopyMipChainData(source, result, dropCount, meta); + return result; + } + + private static unsafe void CopyMipChainData(ScratchImage source, ScratchImage destination, int dropCount, in TexMeta sourceMeta) + { + var destinationMeta = destination.Meta; + var arraySize = Math.Max(1, sourceMeta.ArraySize); + var isCube = sourceMeta.IsCubeMap; + var isVolume = sourceMeta.Dimension == TexDimension.Tex3D; + + for (var item = 0; item < arraySize; item++) + { + for (var mip = 0; mip < destinationMeta.MipLevels; mip++) + { + var sourceMip = mip + dropCount; + var sliceCount = GetSliceCount(sourceMeta, sourceMip, isCube, isVolume); + + for (var slice = 0; slice < sliceCount; slice++) + { + var srcImage = source.GetImage(sourceMip, item, slice); + var dstImage = destination.GetImage(mip, item, slice); + CopyImage(srcImage, dstImage); + } + } + } + } + + private static int GetSliceCount(in TexMeta meta, int mip, bool isCube, bool isVolume) + { + if (isCube) + { + return 6; + } + + if (isVolume) + { + return Math.Max(1, meta.Depth >> mip); + } + + return 1; + } + + private static unsafe void CopyImage(in OtterImage source, in OtterImage destination) + { + var srcPtr = (byte*)source.Pixels; + var dstPtr = (byte*)destination.Pixels; + var bytesToCopy = Math.Min(source.SlicePitch, destination.SlicePitch); + Buffer.MemoryCopy(srcPtr, dstPtr, destination.SlicePitch, bytesToCopy); + } + + private static int ReduceDimension(int value, int iterations) + { + var result = value; + for (var i = 0; i < iterations; i++) + { + result = ReduceDimension(result); + } + + return result; + } + + private static int ReduceDimension(int value) + => value <= 1 ? 1 : Math.Max(1, value / 2); + + private static Image ReduceIndexTexture(Image source, int targetWidth, int targetHeight) + { + var current = source.Clone(); + + while (current.Width > targetWidth || current.Height > targetHeight) + { + var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, current.Width / 2)); + var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, current.Height / 2)); + var next = new Image(nextWidth, nextHeight); + + for (int y = 0; y < nextHeight; y++) + { + var srcY = Math.Min(current.Height - 1, y * 2); + for (int x = 0; x < nextWidth; x++) + { + var srcX = Math.Min(current.Width - 1, x * 2); + + var topLeft = current[srcX, srcY]; + var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY]; + var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)]; + var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)]; + + next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight); + } + } + + current.Dispose(); + current = next; + } + + return current; + } + + private static Image ReduceLinearTexture(Image source, int targetWidth, int targetHeight) + { + var clone = source.Clone(); + + while (clone.Width > targetWidth || clone.Height > targetHeight) + { + var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, clone.Width / 2)); + var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, clone.Height / 2)); + clone.Mutate(ctx => ctx.Resize(nextWidth, nextHeight, KnownResamplers.Lanczos3)); + } + + return clone; + } + + private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight) + { + Span ordered = stackalloc Rgba32[4] + { + bottomLeft, + bottomRight, + topRight, + topLeft + }; + + Span weights = stackalloc float[4]; + var hasContribution = false; + + foreach (var sample in SampleOffsets) + { + if (TryAccumulateSampleWeights(ordered, sample, weights)) + { + hasContribution = true; + } + } + + if (hasContribution) + { + var bestIndex = IndexOfMax(weights); + if (bestIndex >= 0 && weights[bestIndex] > 0f) + { + return ordered[bestIndex]; + } + } + + Span fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight }; + return PickMajorityColor(fallback); + } + + private static readonly Vector2[] SampleOffsets = + { + new(0.25f, 0.25f), + new(0.75f, 0.25f), + new(0.25f, 0.75f), + new(0.75f, 0.75f), + }; + + private static bool TryAccumulateSampleWeights(ReadOnlySpan colors, in Vector2 sampleUv, Span weights) + { + var red = new Vector4( + colors[0].R / 255f, + colors[1].R / 255f, + colors[2].R / 255f, + colors[3].R / 255f); + + var symbols = QuantizeSymbols(red); + var cellUv = ComputeShiftedUv(sampleUv); + + Span order = stackalloc int[4]; + order[0] = 0; + order[1] = 1; + order[2] = 2; + order[3] = 3; + + ApplySymmetry(ref symbols, ref cellUv, order); + + var equality = BuildEquality(symbols, symbols.W); + var selector = BuildSelector(equality, symbols, cellUv); + + const uint lut = 0x00000C07u; + + if (((lut >> (int)selector) & 1u) != 0u) + { + weights[order[3]] += 1f; + return true; + } + + if (selector == 3u) + { + equality = BuildEquality(symbols, symbols.Z); + } + + var weight = ComputeWeight(equality, cellUv); + if (weight <= 1e-6f) + { + return false; + } + + var factor = 1f / weight; + + var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor; + var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor; + var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor; + var wY = equality.Y * cellUv.X * cellUv.Y * factor; + + var contributed = false; + + if (wW > 0f) + { + weights[order[3]] += wW; + contributed = true; + } + + if (wX > 0f) + { + weights[order[0]] += wX; + contributed = true; + } + + if (wZ > 0f) + { + weights[order[2]] += wZ; + contributed = true; + } + + if (wY > 0f) + { + weights[order[1]] += wY; + contributed = true; + } + + return contributed; + } + + private static Vector4 QuantizeSymbols(in Vector4 channel) + => new( + Quantize(channel.X), + Quantize(channel.Y), + Quantize(channel.Z), + Quantize(channel.W)); + + private static float Quantize(float value) + { + var clamped = Math.Clamp(value, 0f, 1f); + return (MathF.Round(clamped * 16f) + 0.5f) / 16f; + } + + private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span order) + { + if (cellUv.X >= 0.5f) + { + symbols = SwapYxwz(symbols, order); + cellUv.X = 1f - cellUv.X; + } + + if (cellUv.Y >= 0.5f) + { + symbols = SwapWzyx(symbols, order); + cellUv.Y = 1f - cellUv.Y; + } + } + + private static Vector4 BuildEquality(in Vector4 symbols, float reference) + => new( + AreEqual(symbols.X, reference) ? 1f : 0f, + AreEqual(symbols.Y, reference) ? 1f : 0f, + AreEqual(symbols.Z, reference) ? 1f : 0f, + AreEqual(symbols.W, reference) ? 1f : 0f); + + private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv) + { + uint selector = 0; + if (equality.X > 0.5f) selector |= 4u; + if (equality.Y > 0.5f) selector |= 8u; + if (equality.Z > 0.5f) selector |= 16u; + if (AreEqual(symbols.X, symbols.Z)) selector |= 2u; + if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u; + + return selector; + } + + private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv) + => equality.W * (1f - cellUv.X) * (1f - cellUv.Y) + + equality.X * (1f - cellUv.X) * cellUv.Y + + equality.Z * cellUv.X * (1f - cellUv.Y) + + equality.Y * cellUv.X * cellUv.Y; + + private static Vector2 ComputeShiftedUv(in Vector2 uv) + { + var shifted = new Vector2( + uv.X - MathF.Floor(uv.X), + uv.Y - MathF.Floor(uv.Y)); + + shifted.X -= 0.5f; + if (shifted.X < 0f) + { + shifted.X += 1f; + } + + shifted.Y -= 0.5f; + if (shifted.Y < 0f) + { + shifted.Y += 1f; + } + + return shifted; + } + + private static Vector4 SwapYxwz(in Vector4 v, Span order) + { + var o0 = order[0]; + var o1 = order[1]; + var o2 = order[2]; + var o3 = order[3]; + + order[0] = o1; + order[1] = o0; + order[2] = o3; + order[3] = o2; + + return new Vector4(v.Y, v.X, v.W, v.Z); + } + + private static Vector4 SwapWzyx(in Vector4 v, Span order) + { + var o0 = order[0]; + var o1 = order[1]; + var o2 = order[2]; + var o3 = order[3]; + + order[0] = o3; + order[1] = o2; + order[2] = o1; + order[3] = o0; + + return new Vector4(v.W, v.Z, v.Y, v.X); + } + + private static int IndexOfMax(ReadOnlySpan values) + { + var bestIndex = -1; + var bestValue = 0f; + + for (var i = 0; i < values.Length; i++) + { + if (values[i] > bestValue) + { + bestValue = values[i]; + bestIndex = i; + } + } + + return bestIndex; + } + + private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f; + + private static Rgba32 PickMajorityColor(ReadOnlySpan colors) + { + var counts = new Dictionary(colors.Length); + foreach (var color in colors) + { + if (counts.TryGetValue(color, out var count)) + { + counts[color] = count + 1; + } + else + { + counts[color] = 1; + } + } + + return counts + .OrderByDescending(kvp => kvp.Value) + .ThenByDescending(kvp => kvp.Key.A) + .ThenByDescending(kvp => kvp.Key.R) + .ThenByDescending(kvp => kvp.Key.G) + .ThenByDescending(kvp => kvp.Key.B) + .First().Key; + } + + private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension) + { + var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1; + return ShouldTrimDimensions(meta.Width, meta.Height, depth, targetMaxDimension); + } + + private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension) + { + if (width <= targetMaxDimension || height <= targetMaxDimension) + { + return false; + } + + if (depth > 1 && depth <= targetMaxDimension) + { + return false; + } + + return true; + } + + private int ResolveTargetMaxDimension() + { + var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension; + if (configured <= 0) + { + return DefaultTargetMaxDimension; + } + + return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension); + } + + private readonly struct TexHeaderInfo + { + public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format) + { + Width = width; + Height = height; + Depth = depth; + MipLevels = mipLevels; + Format = format; + } + + public ushort Width { get; } + public ushort Height { get; } + public ushort Depth { get; } + public ushort MipLevels { get; } + public TexFile.TextureFormat Format { get; } + } + + private static bool TryReadTexHeader(string path, out TexHeaderInfo header) + { + header = default; + + try + { + Span buffer = stackalloc byte[16]; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var read = stream.Read(buffer); + if (read < buffer.Length) + { + return false; + } + + var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]); + var format = (TexFile.TextureFormat)formatValue; + var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]); + var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]); + var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]); + var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]); + header = new TexHeaderInfo(width, height, depth, mipLevels, format); + return true; + } + catch + { + return false; + } + } + + private static bool IsBlockCompressedFormat(TexFile.TextureFormat format) + => TryGetCompressionTarget(format, out _); + + private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target) + { + if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped)) + { + target = mapped; + return true; + } + + target = default; + return false; + } + + private void RegisterDownscaledTexture(string hash, string sourcePath, string destination) + { + _downscaledPaths[hash] = destination; + _logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination); + + var performanceConfig = _playerPerformanceConfigService.Current; + if (performanceConfig.KeepOriginalTextureFiles) + { + return; + } + + if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination)) + { + return; + } + + TryDelete(sourcePath); + } + + private bool TryReplaceCacheEntryWithDownscaled(string hash, string sourcePath, string destination) + { + try + { + var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash); + if (cacheEntry is null || !cacheEntry.IsCacheEntry) + { + return File.Exists(sourcePath) ? false : true; + } + + var cacheFolder = _configService.Current.CacheFolder; + if (string.IsNullOrEmpty(cacheFolder)) + { + return false; + } + + if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var info = new FileInfo(destination); + if (!info.Exists) + { + return false; + } + + var relative = Path.GetRelativePath(cacheFolder, destination) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar); + var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative); + + var replacement = new FileCacheEntity( + hash, + prefixed, + info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), + info.Length, + cacheEntry.CompressedSize); + replacement.SetResolvedFilePath(destination); + + if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase)) + { + _fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath); + } + + _fileCacheManager.UpdateHashedFile(replacement, computeProperties: false); + _fileCacheManager.WriteOutFullCsv(); + + _logger.LogTrace("Replaced cache entry for texture {Hash} to downscaled path {Path}", hash, destination); + return true; + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash); + return false; + } + } + + private string? GetExistingDownscaledPath(string hash) + { + var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex"); + return File.Exists(candidate) ? candidate : null; + } + + private string GetDownscaledDirectory() + { + var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled"); + if (!Directory.Exists(directory)) + { + try + { + Directory.CreateDirectory(directory); + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to create downscaled directory {Directory}", directory); + } + } + + return directory; + } + + private static void TryDelete(string? path) + { + if (string.IsNullOrEmpty(path)) return; + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // ignored + } + } + +} diff --git a/LightlessSync/Services/TextureCompression/TextureMapKind.cs b/LightlessSync/Services/TextureCompression/TextureMapKind.cs new file mode 100644 index 0000000..ed2dee1 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureMapKind.cs @@ -0,0 +1,13 @@ +namespace LightlessSync.Services.TextureCompression; + +public enum TextureMapKind +{ + Diffuse, + Normal, + Specular, + Mask, + Index, + Emissive, + Ui, + Unknown +} diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs new file mode 100644 index 0000000..3c0934c --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -0,0 +1,549 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dalamud.Plugin.Services; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Enums; +using Penumbra.GameData.Files; + +namespace LightlessSync.Services.TextureCompression; + +// ima lie, this isn't garbage + +public sealed class TextureMetadataHelper +{ + private readonly ILogger _logger; + private readonly IDataManager _dataManager; + + private static readonly Dictionary RecommendationCatalog = new() + { + [TextureCompressionTarget.BC1] = ( + "BC1 (Simple Compression for Opaque RGB)", + "This offers a 8:1 compression ratio and is quick with acceptable quality, but only supports RGB, without Alpha.\n\nCan be used for diffuse maps and equipment textures to save extra space."), + [TextureCompressionTarget.BC3] = ( + "BC3 (Simple Compression for RGBA)", + "This offers a 4:1 compression ratio and is quick with acceptable quality, and fully supports RGBA.\n\nGeneric format that can be used for most textures."), + [TextureCompressionTarget.BC4] = ( + "BC4 (Simple Compression for Opaque Grayscale)", + "This offers a 8:1 compression ratio and has almost indistinguishable quality, but only supports Grayscale, without Alpha.\n\nCan be used for face paints and legacy marks."), + [TextureCompressionTarget.BC5] = ( + "BC5 (Simple Compression for Opaque RG)", + "This offers a 4:1 compression ratio and has almost indistinguishable quality, but only supports RG, without B or Alpha.\n\nRecommended for index maps, unrecommended for normal maps."), + [TextureCompressionTarget.BC7] = ( + "BC7 (Complex Compression for RGBA)", + "This offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while.\n\nGeneric format that can be used for most textures.") + }; + + private static readonly (TextureUsageCategory Category, string Token)[] CategoryTokens = + { + (TextureUsageCategory.Ui, "/ui/"), + (TextureUsageCategory.Ui, "/uld/"), + (TextureUsageCategory.Ui, "/icon/"), + + (TextureUsageCategory.VisualEffect, "/vfx/"), + + (TextureUsageCategory.Customization, "/chara/human/"), + (TextureUsageCategory.Customization, "/chara/common/"), + (TextureUsageCategory.Customization, "/chara/bibo"), + + (TextureUsageCategory.Weapon, "/chara/weapon/"), + + (TextureUsageCategory.Accessory, "/chara/accessory/"), + + (TextureUsageCategory.Gear, "/chara/equipment/"), + + (TextureUsageCategory.Monster, "/chara/monster/"), + (TextureUsageCategory.Monster, "/chara/demihuman/"), + + (TextureUsageCategory.MountOrMinion, "/chara/mount/"), + (TextureUsageCategory.MountOrMinion, "/chara/battlepet/"), + + (TextureUsageCategory.Companion, "/chara/companion/"), + + (TextureUsageCategory.Housing, "/hou/"), + (TextureUsageCategory.Housing, "/housing/"), + (TextureUsageCategory.Housing, "/bg/"), + (TextureUsageCategory.Housing, "/bgcommon/") + }; + + private static readonly (TextureUsageCategory Category, string SlotToken, string SlotName)[] SlotTokens = + { + (TextureUsageCategory.Gear, "_met", "Head"), + + (TextureUsageCategory.Gear, "_top", "Body"), + + (TextureUsageCategory.Gear, "_glv", "Hands"), + + (TextureUsageCategory.Gear, "_dwn", "Legs"), + + (TextureUsageCategory.Gear, "_sho", "Feet"), + + (TextureUsageCategory.Accessory, "_ear", "Ears"), + + (TextureUsageCategory.Accessory, "_nek", "Neck"), + + (TextureUsageCategory.Accessory, "_wrs", "Wrists"), + + (TextureUsageCategory.Accessory, "_rir", "Ring"), + + (TextureUsageCategory.Weapon, "_w", "Weapon"), // sussy + (TextureUsageCategory.Weapon, "weapon", "Weapon"), + }; + + private static readonly (TextureMapKind Kind, string Token)[] MapTokens = + { + (TextureMapKind.Normal, "_n"), + (TextureMapKind.Normal, "_normal"), + (TextureMapKind.Normal, "_norm"), + + (TextureMapKind.Mask, "_m"), + (TextureMapKind.Mask, "_mask"), + (TextureMapKind.Mask, "_msk"), + + (TextureMapKind.Specular, "_s"), + (TextureMapKind.Specular, "_spec"), + + (TextureMapKind.Emissive, "_em"), + (TextureMapKind.Emissive, "_glow"), + + (TextureMapKind.Index, "_id"), + (TextureMapKind.Index, "_idx"), + (TextureMapKind.Index, "_index"), + (TextureMapKind.Index, "_multi"), + + (TextureMapKind.Diffuse, "_d"), + (TextureMapKind.Diffuse, "_diff"), + (TextureMapKind.Diffuse, "_b"), + (TextureMapKind.Diffuse, "_base") + }; + + private const string TextureSegment = "/texture/"; + private const string MaterialSegment = "/material/"; + + private const uint NormalSamplerId = 0x0C5EC1F1u; + private const uint IndexSamplerId = 0x565F8FD8u; + private const uint SpecularSamplerId = 0x2B99E025u; + private const uint DiffuseSamplerId = 0x115306BEu; + private const uint MaskSamplerId = 0x8A4E82B6u; + + public TextureMetadataHelper(ILogger logger, IDataManager dataManager) + { + _logger = logger; + _dataManager = dataManager; + } + + public bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info) + => RecommendationCatalog.TryGetValue(target, out info); + + public TextureUsageCategory DetermineCategory(string? gamePath) + { + var normalized = Normalize(gamePath); + if (string.IsNullOrEmpty(normalized)) + return TextureUsageCategory.Unknown; + + var fileName = Path.GetFileName(normalized); + if (!string.IsNullOrEmpty(fileName)) + { + if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase)) + { + return TextureUsageCategory.Customization; + } + } + + if (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("skin", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("gen3", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("tfgen3", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("body", StringComparison.OrdinalIgnoreCase)) + { + return TextureUsageCategory.Customization; + } + + foreach (var (category, token) in CategoryTokens) + { + if (normalized.Contains(token, StringComparison.OrdinalIgnoreCase)) + return category; + } + + var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length >= 2 && string.Equals(segments[0], "chara", StringComparison.OrdinalIgnoreCase)) + { + return segments[1] switch + { + "equipment" => TextureUsageCategory.Gear, + "accessory" => TextureUsageCategory.Accessory, + "weapon" => TextureUsageCategory.Weapon, + "human" or "common" => TextureUsageCategory.Customization, + "monster" or "demihuman" => TextureUsageCategory.Monster, + "mount" or "battlepet" => TextureUsageCategory.MountOrMinion, + "companion" => TextureUsageCategory.Companion, + _ => TextureUsageCategory.Unknown + }; + } + + if (normalized.StartsWith("chara/", StringComparison.OrdinalIgnoreCase) + && (normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("skin", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("body", StringComparison.OrdinalIgnoreCase))) + return TextureUsageCategory.Customization; + + return TextureUsageCategory.Unknown; + } + + public string DetermineSlot(TextureUsageCategory category, string? gamePath) + { + if (category == TextureUsageCategory.Customization) + return GuessCustomizationSlot(gamePath); + + var normalized = Normalize(gamePath); + var fileName = Path.GetFileNameWithoutExtension(normalized); + var searchSource = $"{normalized} {fileName}".ToLowerInvariant(); + + foreach (var (candidateCategory, token, slot) in SlotTokens) + { + if (candidateCategory == category && searchSource.Contains(token, StringComparison.Ordinal)) + return slot; + } + + return category switch + { + TextureUsageCategory.Gear => "Gear", + TextureUsageCategory.Accessory => "Accessory", + TextureUsageCategory.Weapon => "Weapon", + TextureUsageCategory.Monster => "Monster", + TextureUsageCategory.MountOrMinion => "Mount / Minion", + TextureUsageCategory.Companion => "Companion", + TextureUsageCategory.VisualEffect => "VFX", + TextureUsageCategory.Housing => "Housing", + TextureUsageCategory.Ui => "UI", + _ => "General" + }; + } + + public TextureMapKind DetermineMapKind(string path) + => DetermineMapKind(path, null); + + public TextureMapKind DetermineMapKind(string? gamePath, string? localTexturePath) + { + if (TryDetermineFromMaterials(gamePath, localTexturePath, out var kind)) + return kind; + + return GuessMapFromFileName(gamePath ?? localTexturePath ?? string.Empty); + } + + private bool TryDetermineFromMaterials(string? gamePath, string? localTexturePath, out TextureMapKind kind) + { + kind = TextureMapKind.Unknown; + + var candidates = new List(); + AddGameMaterialCandidates(gamePath, candidates); + AddLocalMaterialCandidates(localTexturePath, candidates); + + if (candidates.Count == 0) + return false; + + var normalizedGamePath = Normalize(gamePath); + var normalizedFileName = Path.GetFileName(normalizedGamePath); + + foreach (var candidate in candidates) + { + if (!TryLoadMaterial(candidate, out var material)) + continue; + + if (TryInferKindFromMaterial(material, normalizedGamePath, normalizedFileName, out kind)) + return true; + } + + return false; + } + + private void AddGameMaterialCandidates(string? gamePath, IList candidates) + { + var normalized = Normalize(gamePath); + if (string.IsNullOrEmpty(normalized)) + return; + + var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.Ordinal); + if (textureIndex < 0) + return; + + var prefix = normalized[..textureIndex]; + var suffix = normalized[(textureIndex + TextureSegment.Length)..]; + var baseName = Path.GetFileNameWithoutExtension(suffix); + if (string.IsNullOrEmpty(baseName)) + return; + + var directory = $"{prefix}{MaterialSegment}{Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty}".TrimEnd('/'); + candidates.Add(MaterialCandidate.Game($"{directory}/mt_{baseName}.mtrl")); + + if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx) + { + var trimmed = baseName[(idx + 1)..]; + candidates.Add(MaterialCandidate.Game($"{directory}/mt_{trimmed}.mtrl")); + } + } + + private void AddLocalMaterialCandidates(string? localTexturePath, IList candidates) + { + if (string.IsNullOrEmpty(localTexturePath)) + return; + + var normalized = localTexturePath.Replace('\\', '/'); + var textureIndex = normalized.IndexOf(TextureSegment, StringComparison.OrdinalIgnoreCase); + if (textureIndex >= 0) + { + var prefix = normalized[..textureIndex]; + var suffix = normalized[(textureIndex + TextureSegment.Length)..]; + var folder = Path.GetDirectoryName(suffix)?.Replace('\\', '/') ?? string.Empty; + var baseName = Path.GetFileNameWithoutExtension(suffix); + if (!string.IsNullOrEmpty(baseName)) + { + var materialDir = $"{prefix}{MaterialSegment}{folder}".TrimEnd('/'); + candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{baseName}.mtrl"))); + + if (baseName.StartsWith('v') && baseName.IndexOf('_') is > 0 and var idx) + { + var trimmed = baseName[(idx + 1)..]; + candidates.Add(MaterialCandidate.Local(Path.Combine(materialDir.Replace('/', Path.DirectorySeparatorChar), $"mt_{trimmed}.mtrl"))); + } + } + } + + var textureDirectory = Path.GetDirectoryName(localTexturePath); + if (!string.IsNullOrEmpty(textureDirectory) && Directory.Exists(textureDirectory)) + { + foreach (var candidate in Directory.EnumerateFiles(textureDirectory, "*.mtrl", SearchOption.TopDirectoryOnly)) + candidates.Add(MaterialCandidate.Local(candidate)); + } + } + + private bool TryLoadMaterial(MaterialCandidate candidate, out MtrlFile material) + { + material = null!; + + try + { + switch (candidate.Source) + { + case MaterialSource.Game: + var gameFile = _dataManager.GetFile(candidate.Path); + if (gameFile?.Data.Length > 0) + { + material = new MtrlFile(gameFile.Data); + return material.Valid; + } + break; + + case MaterialSource.Local when File.Exists(candidate.Path): + material = new MtrlFile(File.ReadAllBytes(candidate.Path)); + return material.Valid; + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to load material {Path}", candidate.Path); + } + + return false; + } + + private static bool TryInferKindFromMaterial(MtrlFile material, string normalizedGamePath, string? fileName, out TextureMapKind kind) + { + kind = TextureMapKind.Unknown; + var targetName = fileName ?? string.Empty; + + foreach (var sampler in material.ShaderPackage.Samplers) + { + if (!TryMapSamplerId(sampler.SamplerId, out var candidateKind)) + continue; + + if (sampler.TextureIndex < 0 || sampler.TextureIndex >= material.Textures.Length) + continue; + + var texturePath = Normalize(material.Textures[sampler.TextureIndex].Path); + + if (!string.IsNullOrEmpty(normalizedGamePath) && string.Equals(texturePath, normalizedGamePath, StringComparison.OrdinalIgnoreCase)) + { + kind = candidateKind; + return true; + } + + if (!string.IsNullOrEmpty(targetName) && string.Equals(Path.GetFileName(texturePath), targetName, StringComparison.OrdinalIgnoreCase)) + { + kind = candidateKind; + return true; + } + } + + return false; + } + + private static TextureMapKind GuessMapFromFileName(string path) + { + var normalized = Normalize(path); + var fileName = Path.GetFileNameWithoutExtension(normalized); + if (string.IsNullOrEmpty(fileName)) + return TextureMapKind.Unknown; + + foreach (var (kind, token) in MapTokens) + { + if (fileName.Contains(token, StringComparison.OrdinalIgnoreCase)) + return kind; + } + + return TextureMapKind.Unknown; + } + + public bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target) + { + var normalized = (format ?? string.Empty).ToUpperInvariant(); + if (normalized.Contains("BC1", StringComparison.Ordinal)) + { + target = TextureCompressionTarget.BC1; + return true; + } + + if (normalized.Contains("BC3", StringComparison.Ordinal)) + { + target = TextureCompressionTarget.BC3; + return true; + } + + if (normalized.Contains("BC4", StringComparison.Ordinal)) + { + target = TextureCompressionTarget.BC4; + return true; + } + + if (normalized.Contains("BC5", StringComparison.Ordinal)) + { + target = TextureCompressionTarget.BC5; + return true; + } + + if (normalized.Contains("BC7", StringComparison.Ordinal)) + { + target = TextureCompressionTarget.BC7; + return true; + } + + target = TextureCompressionTarget.BC7; + return false; + } + + public (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind) + { + TextureCompressionTarget? current = null; + if (TryMapFormatToTarget(format, out var mapped)) + current = mapped; + + var suggestion = mapKind switch + { + TextureMapKind.Normal => TextureCompressionTarget.BC7, + TextureMapKind.Mask => TextureCompressionTarget.BC4, + TextureMapKind.Index => TextureCompressionTarget.BC3, + TextureMapKind.Specular => TextureCompressionTarget.BC4, + TextureMapKind.Emissive => TextureCompressionTarget.BC3, + TextureMapKind.Diffuse => TextureCompressionTarget.BC7, + _ => TextureCompressionTarget.BC7 + }; + + if (mapKind == TextureMapKind.Diffuse && !HasAlphaHint(format)) + suggestion = TextureCompressionTarget.BC1; + + if (current == suggestion) + return null; + + return (suggestion, RecommendationCatalog.TryGetValue(suggestion, out var info) + ? info.Description + : "Suggested to balance visual quality and file size."); + } + + private static bool TryMapSamplerId(uint id, out TextureMapKind kind) + { + kind = id switch + { + NormalSamplerId => TextureMapKind.Normal, + IndexSamplerId => TextureMapKind.Index, + SpecularSamplerId => TextureMapKind.Specular, + DiffuseSamplerId => TextureMapKind.Diffuse, + MaskSamplerId => TextureMapKind.Mask, + _ => TextureMapKind.Unknown + }; + + return kind != TextureMapKind.Unknown; + } + + private static string GuessCustomizationSlot(string? gamePath) + { + var normalized = Normalize(gamePath); + var fileName = Path.GetFileName(normalized); + + if (!string.IsNullOrEmpty(fileName)) + { + if (fileName.StartsWith("bibo", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("gen3", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("tfgen3", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("skin", StringComparison.OrdinalIgnoreCase)) + { + return "Skin"; + } + } + + if (normalized.Contains("hair", StringComparison.OrdinalIgnoreCase)) + return "Hair"; + if (normalized.Contains("face", StringComparison.OrdinalIgnoreCase)) + return "Face"; + if (normalized.Contains("tail", StringComparison.OrdinalIgnoreCase)) + return "Tail"; + if (normalized.Contains("zear", StringComparison.OrdinalIgnoreCase)) + return "Ear"; + if (normalized.Contains("eye", StringComparison.OrdinalIgnoreCase)) + return "Eye"; + if (normalized.Contains("body", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("skin", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)) + return "Skin"; + if (normalized.Contains("decal_face", StringComparison.OrdinalIgnoreCase)) + return "Face Paint"; + if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase)) + return "Equipment Decal"; + + return "Customization"; + } + + private static bool HasAlphaHint(string? format) + { + if (string.IsNullOrEmpty(format)) + return false; + + var normalized = format.ToUpperInvariant(); + return normalized.Contains("A8", StringComparison.Ordinal) + || normalized.Contains("ARGB", StringComparison.Ordinal) + || normalized.Contains("BC3", StringComparison.Ordinal) + || normalized.Contains("BC7", StringComparison.Ordinal); + } + + private static string Normalize(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + return path.Replace('\\', '/').ToLowerInvariant(); + } + + private readonly record struct MaterialCandidate(string Path, MaterialSource Source) + { + public static MaterialCandidate Game(string path) => new(path, MaterialSource.Game); + public static MaterialCandidate Local(string path) => new(path, MaterialSource.Local); + } + + private enum MaterialSource + { + Game, + Local + } +} diff --git a/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs b/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs new file mode 100644 index 0000000..c4af7b7 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs @@ -0,0 +1,16 @@ +namespace LightlessSync.Services.TextureCompression; + +public enum TextureUsageCategory +{ + Gear, + Weapon, + Accessory, + Customization, + MountOrMinion, + Companion, + Monster, + Housing, + Ui, + VisualEffect, + Unknown +} diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 248eae0..435d3c2 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -1,10 +1,13 @@ -using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.ImGuiFileDialog; +using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI; +using LightlessSync.UI.Tags; using LightlessSync.WebAPI; +using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; @@ -15,42 +18,131 @@ public class UiFactory private readonly LightlessMediator _lightlessMediator; private readonly ApiController _apiController; private readonly UiSharedService _uiSharedService; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly ServerConfigurationManager _serverConfigManager; private readonly LightlessProfileManager _lightlessProfileManager; private readonly PerformanceCollectorService _performanceCollectorService; private readonly FileDialogManager _fileDialogManager; + private readonly ProfileTagService _profileTagService; - public UiFactory(ILoggerFactory loggerFactory, LightlessMediator lightlessMediator, ApiController apiController, - UiSharedService uiSharedService, PairManager pairManager, ServerConfigurationManager serverConfigManager, - LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, FileDialogManager fileDialogManager) + public UiFactory( + ILoggerFactory loggerFactory, + LightlessMediator lightlessMediator, + ApiController apiController, + UiSharedService uiSharedService, + PairUiService pairUiService, + ServerConfigurationManager serverConfigManager, + LightlessProfileManager lightlessProfileManager, + PerformanceCollectorService performanceCollectorService, + FileDialogManager fileDialogManager, + ProfileTagService profileTagService) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; _apiController = apiController; _uiSharedService = uiSharedService; - _pairManager = pairManager; + _pairUiService = pairUiService; _serverConfigManager = serverConfigManager; _lightlessProfileManager = lightlessProfileManager; _performanceCollectorService = performanceCollectorService; _fileDialogManager = fileDialogManager; + _profileTagService = profileTagService; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) { - return new SyncshellAdminUI(_loggerFactory.CreateLogger(), _lightlessMediator, - _apiController, _uiSharedService, _pairManager, dto, _performanceCollectorService, _lightlessProfileManager, _fileDialogManager); + return new SyncshellAdminUI( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _apiController, + _uiSharedService, + _pairUiService, + dto, + _performanceCollectorService, + _lightlessProfileManager, + _fileDialogManager); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) { - return new StandaloneProfileUi(_loggerFactory.CreateLogger(), _lightlessMediator, - _uiSharedService, _serverConfigManager, _lightlessProfileManager, _pairManager, pair, _performanceCollectorService); + return new StandaloneProfileUi( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _uiSharedService, + _serverConfigManager, + _profileTagService, + _lightlessProfileManager, + _pairUiService, + pair, + pair.UserData, + null, + false, + null, + _performanceCollectorService); + } + + public StandaloneProfileUi CreateStandaloneProfileUi(UserData userData) + { + return new StandaloneProfileUi( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _uiSharedService, + _serverConfigManager, + _profileTagService, + _lightlessProfileManager, + _pairUiService, + null, + userData, + null, + false, + null, + _performanceCollectorService); + } + + public StandaloneProfileUi CreateLightfinderProfileUi(UserData userData, string hashedCid) + { + return new StandaloneProfileUi( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _uiSharedService, + _serverConfigManager, + _profileTagService, + _lightlessProfileManager, + _pairUiService, + null, + userData, + null, + true, + hashedCid, + _performanceCollectorService); + } + + public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupFullInfoDto groupInfo) + { + return new StandaloneProfileUi( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _uiSharedService, + _serverConfigManager, + _profileTagService, + _lightlessProfileManager, + _pairUiService, + null, + null, + groupInfo, + false, + null, + _performanceCollectorService); } public PermissionWindowUI CreatePermissionPopupUi(Pair pair) { - return new PermissionWindowUI(_loggerFactory.CreateLogger(), pair, - _lightlessMediator, _uiSharedService, _apiController, _performanceCollectorService); + return new PermissionWindowUI( + _loggerFactory.CreateLogger(), + pair, + _lightlessMediator, + _uiSharedService, + _apiController, + _performanceCollectorService); } } diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index f08b1fc..4951bec 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -2,6 +2,7 @@ using Dalamud.Interface; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Windowing; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Factories; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.UI.Style; @@ -18,12 +19,13 @@ public sealed class UiService : DisposableMediatorSubscriberBase private readonly LightlessConfigService _lightlessConfigService; private readonly WindowSystem _windowSystem; private readonly UiFactory _uiFactory; + private readonly PairFactory _pairFactory; public UiService(ILogger logger, IUiBuilder uiBuilder, LightlessConfigService lightlessConfigService, WindowSystem windowSystem, IEnumerable windows, UiFactory uiFactory, FileDialogManager fileDialogManager, - LightlessMediator lightlessMediator) : base(logger, lightlessMediator) + LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator) { _logger = logger; _logger.LogTrace("Creating {type}", GetType().Name); @@ -31,6 +33,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase _lightlessConfigService = lightlessConfigService; _windowSystem = windowSystem; _uiFactory = uiFactory; + _pairFactory = pairFactory; _fileDialogManager = fileDialogManager; _uiBuilder.DisableGposeUiHide = true; @@ -45,10 +48,101 @@ public sealed class UiService : DisposableMediatorSubscriberBase Mediator.Subscribe(this, (msg) => { + var resolvedPair = _pairFactory.Create(msg.Pair.UniqueIdent) ?? msg.Pair; if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui - && string.Equals(ui.Pair.UserData.AliasOrUID, msg.Pair.UserData.AliasOrUID, StringComparison.Ordinal))) + && ui.Pair != null + && ui.Pair.UniqueIdent == resolvedPair.UniqueIdent)) { - var window = _uiFactory.CreateStandaloneProfileUi(msg.Pair); + var window = _uiFactory.CreateStandaloneProfileUi(resolvedPair); + _createdWindows.Add(window); + _windowSystem.AddWindow(window); + } + }); + + Mediator.Subscribe(this, msg => + { + var existingWindow = _createdWindows.Find(p => p is StandaloneProfileUi ui + && ui.IsGroupProfile + && ui.ProfileGroupData is not null + && string.Equals(ui.ProfileGroupData.GID, msg.Group.Group.GID, StringComparison.Ordinal)); + + if (existingWindow is StandaloneProfileUi existing) + { + existing.IsOpen = true; + } + else + { + var window = _uiFactory.CreateStandaloneGroupProfileUi(msg.Group); + _createdWindows.Add(window); + _windowSystem.AddWindow(window); + } + }); + + Mediator.Subscribe(this, msg => + { + var window = _createdWindows.Find(p => p is StandaloneProfileUi ui + && ui.IsGroupProfile + && ui.ProfileGroupData is not null + && string.Equals(ui.ProfileGroupData.GID, msg.Group.Group.GID, StringComparison.Ordinal)); + + if (window is not null) + { + _windowSystem.RemoveWindow(window); + _createdWindows.Remove(window); + window.Dispose(); + } + }); + + Mediator.Subscribe(this, msg => + { + if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui + && ui.Pair is null + && !ui.IsGroupProfile + && !ui.IsLightfinderContext + && string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal))) + { + var window = _uiFactory.CreateStandaloneProfileUi(msg.User); + _createdWindows.Add(window); + _windowSystem.AddWindow(window); + } + }); + + Mediator.Subscribe(this, msg => + { + var window = _createdWindows.Find(p => p is StandaloneProfileUi ui + && ui.Pair is null + && !ui.IsGroupProfile + && !ui.IsLightfinderContext + && string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal)); + + if (window is not null) + { + _windowSystem.RemoveWindow(window); + _createdWindows.Remove(window); + window.Dispose(); + } + }); + + Mediator.Subscribe(this, msg => + { + if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui && ui.IsLightfinderContext && string.Equals(ui.LightfinderCid, msg.HashedCid, StringComparison.Ordinal))) + { + var window = _uiFactory.CreateLightfinderProfileUi(msg.User, msg.HashedCid); + _createdWindows.Add(window); + _windowSystem.AddWindow(window); + } + }); + + Mediator.Subscribe(this, msg => + { + if (!_createdWindows.Exists(p => p is StandaloneProfileUi ui + && !ui.IsLightfinderContext + && !ui.IsGroupProfile + && ui.Pair is null + && ui.ProfileUserData is not null + && string.Equals(ui.ProfileUserData.UID, msg.User.UID, StringComparison.Ordinal))) + { + var window = _uiFactory.CreateStandaloneProfileUi(msg.User); _createdWindows.Add(window); _windowSystem.AddWindow(window); } @@ -67,10 +161,12 @@ public sealed class UiService : DisposableMediatorSubscriberBase Mediator.Subscribe(this, (msg) => { + var resolvedPair = _pairFactory.Create(msg.Pair.UniqueIdent) ?? msg.Pair; if (!_createdWindows.Exists(p => p is PermissionWindowUI ui - && msg.Pair == ui.Pair)) + && ui.Pair is not null + && ui.Pair.UniqueIdent == resolvedPair.UniqueIdent)) { - var window = _uiFactory.CreatePermissionPopupUi(msg.Pair); + var window = _uiFactory.CreatePermissionPopupUi(resolvedPair); _createdWindows.Add(window); _windowSystem.AddWindow(window); } diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs index c760a45..c008f31 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/BroadcastUI.cs @@ -143,9 +143,9 @@ namespace LightlessSync.UI _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"),"This lets other Lightless users know you use Lightless. While enabled, you and others using Lightfinder can see each other identified as Lightless users."); ImGui.Indent(15f); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - ImGui.Text("- This is done using a 'Lightless' label above player nameplates."); - ImGui.PopStyleColor(); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.Text("- This is done using a 'Lightless' label above player nameplates."); + ImGui.PopStyleColor(); ImGui.Unindent(15f); ImGuiHelpers.ScaledDummy(3f); @@ -369,7 +369,7 @@ namespace LightlessSync.UI ImGui.EndTabItem(); } - #if DEBUG +#if DEBUG if (ImGui.BeginTabItem("Debug")) { ImGui.Text("Broadcast Cache"); @@ -428,7 +428,7 @@ namespace LightlessSync.UI ImGui.EndTabItem(); } - #endif +#endif ImGui.EndTabBar(); } diff --git a/LightlessSync/UI/CharaDataHubUi.McdOnline.cs b/LightlessSync/UI/CharaDataHubUi.McdOnline.cs index e86ef10..dc6b572 100644 --- a/LightlessSync/UI/CharaDataHubUi.McdOnline.cs +++ b/LightlessSync/UI/CharaDataHubUi.McdOnline.cs @@ -795,11 +795,12 @@ internal sealed partial class CharaDataHubUi { UiSharedService.DrawTree("Access for Specific Individuals / Syncshells", () => { + var snapshot = _pairUiService.GetSnapshot(); using (ImRaii.PushId("user")) { using (ImRaii.Group()) { - InputComboHybrid("##AliasToAdd", "##AliasToAddPicker", ref _specificIndividualAdd, _pairManager.PairsWithGroups.Keys, + InputComboHybrid("##AliasToAdd", "##AliasToAddPicker", ref _specificIndividualAdd, snapshot.PairsWithGroups.Keys, static pair => (pair.UserData.UID, pair.UserData.Alias, pair.UserData.AliasOrUID, pair.GetNote())); ImGui.SameLine(); using (ImRaii.Disabled(string.IsNullOrEmpty(_specificIndividualAdd) @@ -868,8 +869,8 @@ internal sealed partial class CharaDataHubUi { using (ImRaii.Group()) { - InputComboHybrid("##GroupAliasToAdd", "##GroupAliasToAddPicker", ref _specificGroupAdd, _pairManager.Groups.Keys, - group => (group.GID, group.Alias, group.AliasOrGID, _serverConfigurationManager.GetNoteForGid(group.GID))); + InputComboHybrid("##GroupAliasToAdd", "##GroupAliasToAddPicker", ref _specificGroupAdd, snapshot.Groups, + group => (group.GID, group.GroupAliasOrGID, group.GroupAliasOrGID, _serverConfigurationManager.GetNoteForGid(group.GID))); ImGui.SameLine(); using (ImRaii.Disabled(string.IsNullOrEmpty(_specificGroupAdd) || updateDto.GroupList.Any(f => string.Equals(f.GID, _specificGroupAdd, StringComparison.Ordinal) || string.Equals(f.Alias, _specificGroupAdd, StringComparison.Ordinal)))) diff --git a/LightlessSync/UI/CharaDataHubUi.cs b/LightlessSync/UI/CharaDataHubUi.cs index 51723b9..b50b819 100644 --- a/LightlessSync/UI/CharaDataHubUi.cs +++ b/LightlessSync/UI/CharaDataHubUi.cs @@ -7,7 +7,6 @@ using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Dto.CharaData; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.CharaData; using LightlessSync.Services.CharaData.Models; @@ -15,6 +14,8 @@ using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Utils; using Microsoft.Extensions.Logging; +using LightlessSync.UI.Services; +using LightlessSync.PlayerData.Pairs; namespace LightlessSync.UI; @@ -26,7 +27,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase private readonly CharaDataConfigService _configService; private readonly DalamudUtilService _dalamudUtilService; private readonly FileDialogManager _fileDialogManager; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly CharaDataGposeTogetherManager _charaDataGposeTogetherManager; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiSharedService; @@ -77,7 +78,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase public CharaDataHubUi(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollectorService, CharaDataManager charaDataManager, CharaDataNearbyManager charaDataNearbyManager, CharaDataConfigService configService, UiSharedService uiSharedService, ServerConfigurationManager serverConfigurationManager, - DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairManager pairManager, + DalamudUtilService dalamudUtilService, FileDialogManager fileDialogManager, PairUiService pairUiService, CharaDataGposeTogetherManager charaDataGposeTogetherManager) : base(logger, mediator, "Lightless Sync Character Data Hub###LightlessSyncCharaDataUI", performanceCollectorService) { @@ -90,7 +91,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase _serverConfigurationManager = serverConfigurationManager; _dalamudUtilService = dalamudUtilService; _fileDialogManager = fileDialogManager; - _pairManager = pairManager; + _pairUiService = pairUiService; _charaDataGposeTogetherManager = charaDataGposeTogetherManager; Mediator.Subscribe(this, (_) => IsOpen |= _configService.Current.OpenLightlessHubOnGposeStart); Mediator.Subscribe(this, (msg) => diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index cc8d326..40b0f0e 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -16,6 +16,8 @@ using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Components; using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; @@ -38,11 +40,12 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly ApiController _apiController; private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; + private readonly PairLedger _pairLedger; private readonly ConcurrentDictionary> _currentDownloads = new(); private readonly DrawEntityFactory _drawEntityFactory; private readonly FileUploadManager _fileTransferManager; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly SelectTagForPairUi _selectTagForPairUi; private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; @@ -65,13 +68,15 @@ public class CompactUi : WindowMediatorSubscriberBase private float _transferPartHeight; private bool _wasOpen; private float _windowContentWidth; + private readonly SeluneBrush _seluneBrush = new(); + private const float ConnectButtonHighlightThickness = 14f; public CompactUi( ILogger logger, UiSharedService uiShared, LightlessConfigService configService, ApiController apiController, - PairManager pairManager, + PairUiService pairUiService, ServerConfigurationManager serverManager, LightlessMediator mediator, FileUploadManager fileTransferManager, @@ -87,12 +92,12 @@ public class CompactUi : WindowMediatorSubscriberBase IpcManager ipcManager, BroadcastService broadcastService, CharacterAnalyzer characterAnalyzer, - PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) + PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; _configService = configService; _apiController = apiController; - _pairManager = pairManager; + _pairUiService = pairUiService; _serverManager = serverManager; _fileTransferManager = fileTransferManager; _tagHandler = tagHandler; @@ -105,7 +110,8 @@ public class CompactUi : WindowMediatorSubscriberBase _renamePairTagUi = renameTagUi; _ipcManager = ipcManager; _broadcastService = broadcastService; - _tabMenu = new TopTabMenu(Mediator, _apiController, _pairManager, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService); + _pairLedger = pairLedger; + _tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService); AllowPinning = true; AllowClickthrough = false; @@ -176,6 +182,11 @@ public class CompactUi : WindowMediatorSubscriberBase protected override void DrawInternal() { + var drawList = ImGui.GetWindowDrawList(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); + _windowContentWidth = UiSharedService.GetWindowContentRegionWidth(); if (!_apiController.IsCurrentVersion) { @@ -223,29 +234,47 @@ public class CompactUi : WindowMediatorSubscriberBase using (ImRaii.PushId("header")) DrawUIDHeader(); _uiSharedService.RoundedSeparator(UIColors.Get("LightlessPurple"), 2.5f, 1f, 12f); - using (ImRaii.PushId("serverstatus")) DrawServerStatus(); + using (ImRaii.PushId("serverstatus")) + { + DrawServerStatus(); + } + selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + var style = ImGui.GetStyle(); + var contentMinY = windowPos.Y + ImGui.GetWindowContentRegionMin().Y; + var gradientInset = 4f * ImGuiHelpers.GlobalScale; + var gradientTop = MathF.Max(contentMinY, ImGui.GetCursorScreenPos().Y - style.ItemSpacing.Y + gradientInset); ImGui.Separator(); if (_apiController.ServerState is ServerState.Connected) { - using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(); + var pairSnapshot = _pairUiService.GetSnapshot(); + + using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); ImGui.Separator(); + var transfersTop = ImGui.GetCursorScreenPos().Y; + var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); + selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); float pairlistEnd = ImGui.GetCursorPosY(); using (ImRaii.PushId("transfers")) DrawTransfers(); _transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight(); - using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(_pairManager.DirectPairs); - using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw([.. _pairManager.Groups.Values]); + using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs); + using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups); using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw(); using (ImRaii.PushId("group-syncshell-edit")) _renameSyncshellTagUi.Draw(); using (ImRaii.PushId("grouping-pair-popup")) _selectTagForPairUi.Draw(); using (ImRaii.PushId("grouping-syncshell-popup")) _selectTagForSyncshellUi.Draw(); } - - if (_configService.Current.OpenPopupOnAdd && _pairManager.LastAddedUser != null) + else { - _lastAddedUser = _pairManager.LastAddedUser; - _pairManager.LastAddedUser = null; + selune.Animate(ImGui.GetIO().DeltaTime); + } + + var lastAddedPair = _pairUiService.GetLastAddedPair(); + if (_configService.Current.OpenPopupOnAdd && lastAddedPair is not null) + { + _lastAddedUser = lastAddedPair; + _pairUiService.ClearLastAddedPair(); ImGui.OpenPopup("Set Notes for New User"); _showModalForUserAddition = true; _lastAddedUserComment = string.Empty; @@ -290,15 +319,17 @@ public class CompactUi : WindowMediatorSubscriberBase : (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y + ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY(); - ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false); - - foreach (var item in _drawFolders) + if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false)) { - item.Draw(); + foreach (var item in _drawFolders) + { + item.Draw(); + } } ImGui.EndChild(); } + private void DrawServerStatus() { var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Link); @@ -371,6 +402,19 @@ public class CompactUi : WindowMediatorSubscriberBase } } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight( + ImGui.GetItemRectMin(), + ImGui.GetItemRectMax(), + SeluneHighlightMode.Both, + borderOnly: true, + borderThicknessOverride: ConnectButtonHighlightThickness, + exactSize: true, + clipToElement: true, + roundingOverride: ImGui.GetStyle().FrameRounding); + } + UiSharedService.AttachToolTip(isConnectingOrConnected ? "Disconnect from " + _serverManager.CurrentServer.ServerName : "Connect to " + _serverManager.CurrentServer.ServerName); } } @@ -527,6 +571,17 @@ public class CompactUi : WindowMediatorSubscriberBase if (ImGui.IsItemHovered()) { + var padding = new Vector2(10f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + ImGui.GetItemRectMin() - padding, + ImGui.GetItemRectMax() + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: UIColors.Get("LightlessGreen"), + highlightAlphaOverride: 0.2f); + ImGui.BeginTooltip(); ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); @@ -603,6 +658,20 @@ public class CompactUi : WindowMediatorSubscriberBase } } + if (ImGui.IsItemHovered()) + { + var padding = new Vector2(35f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + ImGui.GetItemRectMin() - padding, + ImGui.GetItemRectMax() + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: vanityGlowColor, + highlightAlphaOverride: 0.05f); + } + headerItemClicked = ImGui.IsItemClicked(); if (headerItemClicked) @@ -675,6 +744,20 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.TextColored(GetUidColor(), _apiController.UID); } + if (ImGui.IsItemHovered()) + { + var padding = new Vector2(30f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + ImGui.GetItemRectMin() - padding, + ImGui.GetItemRectMax() + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: vanityGlowColor, + highlightAlphaOverride: 0.05f); + } + bool uidFooterClicked = ImGui.IsItemClicked(); UiSharedService.AttachToolTip("Click to copy"); if (uidFooterClicked) @@ -696,28 +779,45 @@ public class CompactUi : WindowMediatorSubscriberBase var drawFolders = new List(); var filter = _tabMenu.Filter; - var allPairs = _pairManager.PairsWithGroups.ToDictionary(k => k.Key, k => k.Value); - var filteredPairs = allPairs.Where(p => PassesFilter(p.Key, filter)).ToDictionary(k => k.Key, k => k.Value); + var allEntries = _drawEntityFactory.GetAllEntries().ToList(); + var filteredEntries = string.IsNullOrEmpty(filter) + ? allEntries + : allEntries.Where(e => PassesFilter(e, filter)).ToList(); + + var syncshells = _pairLedger.GetAllSyncshells(); + var groupInfos = syncshells.Values + .Select(s => s.GroupFullInfo) + .OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var entryLookup = allEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal); + var filteredEntryLookup = filteredEntries.ToDictionary(e => e.DisplayEntry.Ident.UserId, StringComparer.Ordinal); //Filter of online/visible pairs if (_configService.Current.ShowVisibleUsersSeparately) { - var allVisiblePairs = ImmutablePairList(allPairs.Where(p => FilterVisibleUsers(p.Key))); - var filteredVisiblePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterVisibleUsers(p.Key))); - - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs)); + var allVisiblePairs = SortVisibleEntries(allEntries.Where(FilterVisibleUsers)); + if (allVisiblePairs.Count > 0) + { + var filteredVisiblePairs = SortVisibleEntries(filteredEntries.Where(FilterVisibleUsers)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomVisibleTag, filteredVisiblePairs, allVisiblePairs)); + } } //Filter of not foldered syncshells var groupFolders = new List(); - foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) + foreach (var group in groupInfos) { - GetGroups(allPairs, filteredPairs, group, out ImmutableList allGroupPairs, out Dictionary> filteredGroupPairs); - - if (FilterNotTaggedSyncshells(group)) + if (!FilterNotTaggedSyncshells(group)) { - groupFolders.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder(group, filteredGroupPairs, allGroupPairs))); + continue; } + + var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false); + var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true); + // Always create the folder so empty syncshells remain visible in the UI. + var drawGroupFolder = _drawEntityFactory.CreateGroupFolder(group.Group.GID, group, filteredGroupEntries, allGroupEntries); + groupFolders.Add(new GroupFolder(group, drawGroupFolder)); } //Filter of grouped up syncshells (All Syncshells Folder) @@ -730,123 +830,215 @@ public class CompactUi : WindowMediatorSubscriberBase //Filter of grouped/foldered pairs foreach (var tag in _tagHandler.GetAllPairTagsSorted()) { - var allTagPairs = ImmutablePairList(allPairs.Where(p => FilterTagUsers(p.Key, tag))); - var filteredTagPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterTagUsers(p.Key, tag) && FilterOnlineOrPausedSelf(p.Key))); - - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(tag, filteredTagPairs, allTagPairs)); + var allTagPairs = SortEntries(allEntries.Where(e => FilterTagUsers(e, tag))); + if (allTagPairs.Count > 0) + { + var filteredTagPairs = SortEntries(filteredEntries.Where(e => FilterTagUsers(e, tag) && FilterOnlineOrPausedSelf(e))); + drawFolders.Add(_drawEntityFactory.CreateTagFolder(tag, filteredTagPairs, allTagPairs)); + } } //Filter of grouped/foldered syncshells foreach (var syncshellTag in _tagHandler.GetAllSyncshellTagsSorted()) { var syncshellFolderTags = new List(); - foreach (var group in _pairManager.GroupPairs.Select(g => g.Key).OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase)) + foreach (var group in groupInfos) { - if (_tagHandler.HasSyncshellTag(group.GID, syncshellTag)) + if (!_tagHandler.HasSyncshellTag(group.Group.GID, syncshellTag)) { - GetGroups(allPairs, filteredPairs, group, - out ImmutableList allGroupPairs, - out Dictionary> filteredGroupPairs); - - syncshellFolderTags.Add(new GroupFolder(group, _drawEntityFactory.CreateDrawGroupFolder($"tag_{group.GID}", group, filteredGroupPairs, allGroupPairs))); + continue; } + + var allGroupEntries = ResolveGroupEntries(entryLookup, syncshells, group, applyFilters: false); + var filteredGroupEntries = ResolveGroupEntries(filteredEntryLookup, syncshells, group, applyFilters: true); + // Keep tagged syncshells rendered regardless of whether membership info has loaded. + var taggedGroupFolder = _drawEntityFactory.CreateGroupFolder($"tag_{group.Group.GID}", group, filteredGroupEntries, allGroupEntries); + syncshellFolderTags.Add(new GroupFolder(group, taggedGroupFolder)); } drawFolders.Add(new DrawGroupedGroupFolder(syncshellFolderTags, _tagHandler, _apiController, _uiSharedService, _selectSyncshellForTagUi, _renameSyncshellTagUi, syncshellTag)); } //Filter of not grouped/foldered and offline pairs - var allOnlineNotTaggedPairs = ImmutablePairList(allPairs.Where(p => FilterNotTaggedUsers(p.Key))); - var onlineNotTaggedPairs = BasicSortedDictionary(filteredPairs.Where(p => FilterNotTaggedUsers(p.Key) && FilterOnlineOrPausedSelf(p.Key))); + var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers)); + var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder((_configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag), onlineNotTaggedPairs, allOnlineNotTaggedPairs)); + if (allOnlineNotTaggedPairs.Count > 0) + { + drawFolders.Add(_drawEntityFactory.CreateTagFolder( + _configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag, + onlineNotTaggedPairs, + allOnlineNotTaggedPairs)); + } if (_configService.Current.ShowOfflineUsersSeparately) { - var allOfflinePairs = ImmutablePairList(allPairs.Where(p => FilterOfflineUsers(p.Key, p.Value))); - var filteredOfflinePairs = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineUsers(p.Key, p.Value))); - - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs)); + var allOfflinePairs = SortEntries(allEntries.Where(FilterOfflineUsers)); + if (allOfflinePairs.Count > 0) + { + var filteredOfflinePairs = SortEntries(filteredEntries.Where(FilterOfflineUsers)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineTag, filteredOfflinePairs, allOfflinePairs)); + } if (_configService.Current.ShowSyncshellOfflineUsersSeparately) { - var allOfflineSyncshellUsers = ImmutablePairList(allPairs.Where(p => FilterOfflineSyncshellUsers(p.Key))); - var filteredOfflineSyncshellUsers = BasicSortedDictionary(filteredPairs.Where(p => FilterOfflineSyncshellUsers(p.Key))); - - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers)); + var allOfflineSyncshellUsers = SortEntries(allEntries.Where(FilterOfflineSyncshellUsers)); + if (allOfflineSyncshellUsers.Count > 0) + { + var filteredOfflineSyncshellUsers = SortEntries(filteredEntries.Where(FilterOfflineSyncshellUsers)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomOfflineSyncshellTag, filteredOfflineSyncshellUsers, allOfflineSyncshellUsers)); + } } } - //Unpaired - drawFolders.Add(_drawEntityFactory.CreateDrawTagFolder(TagHandler.CustomUnpairedTag, - BasicSortedDictionary(filteredPairs.Where(p => p.Key.IsOneSidedPair)), - ImmutablePairList(allPairs.Where(p => p.Key.IsOneSidedPair)))); + //Unpaired + var unpairedAllEntries = SortEntries(allEntries.Where(e => e.IsOneSided)); + if (unpairedAllEntries.Count > 0) + { + var unpairedFiltered = SortEntries(filteredEntries.Where(e => e.IsOneSided)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder(TagHandler.CustomUnpairedTag, unpairedFiltered, unpairedAllEntries)); + } return drawFolders; } } - private static bool PassesFilter(Pair pair, string filter) + private bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; - return pair.UserData.AliasOrUID.Contains(filter, StringComparison.OrdinalIgnoreCase) || (pair.GetNote()?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) || (pair.PlayerName?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); + return entry.AliasOrUid.Contains(filter, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(entry.Note) && entry.Note.Contains(filter, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(entry.DisplayName) && entry.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)); } - private string AlphabeticalSortKey(Pair pair) + private string AlphabeticalSortKey(PairUiEntry entry) { - if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(pair.PlayerName)) + if (_configService.Current.ShowCharacterNameInsteadOfNotesForVisible && !string.IsNullOrEmpty(entry.DisplayName)) { - return _configService.Current.PreferNotesOverNamesForVisible ? (pair.GetNote() ?? string.Empty) : pair.PlayerName; + return _configService.Current.PreferNotesOverNamesForVisible ? (entry.Note ?? string.Empty) : entry.DisplayName; } - return pair.GetNote() ?? pair.UserData.AliasOrUID; + return !string.IsNullOrEmpty(entry.Note) ? entry.Note : entry.AliasOrUid; } - private bool FilterOnlineOrPausedSelf(Pair pair) => pair.IsOnline || (!pair.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || pair.UserPair.OwnPermissions.IsPaused(); + private bool FilterOnlineOrPausedSelf(PairUiEntry entry) => entry.IsOnline || (!entry.IsOnline && !_configService.Current.ShowOfflineUsersSeparately) || entry.SelfPermissions.IsPaused(); - private bool FilterVisibleUsers(Pair pair) => pair.IsVisible && (_configService.Current.ShowSyncshellUsersInVisible || pair.IsDirectlyPaired); + private bool FilterVisibleUsers(PairUiEntry entry) => entry.IsVisible && entry.IsOnline && (_configService.Current.ShowSyncshellUsersInVisible || entry.IsDirectlyPaired); - private bool FilterTagUsers(Pair pair, string tag) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && _tagHandler.HasPairTag(pair.UserData.UID, tag); + private bool FilterTagUsers(PairUiEntry entry, string tag) => entry.IsDirectlyPaired && !entry.IsOneSided && _tagHandler.HasPairTag(entry.DisplayEntry.Ident.UserId, tag); - private static bool FilterGroupUsers(List groups, GroupFullInfoDto group) => groups.Exists(g => string.Equals(g.GID, group.GID, StringComparison.Ordinal)); - - private bool FilterNotTaggedUsers(Pair pair) => pair.IsDirectlyPaired && !pair.IsOneSidedPair && !_tagHandler.HasAnyPairTag(pair.UserData.UID); + private bool FilterNotTaggedUsers(PairUiEntry entry) => entry.IsDirectlyPaired && !entry.IsOneSided && !_tagHandler.HasAnyPairTag(entry.DisplayEntry.Ident.UserId); private bool FilterNotTaggedSyncshells(GroupFullInfoDto group) => !_tagHandler.HasAnySyncshellTag(group.GID) || _configService.Current.ShowGroupedSyncshellsInAll; - private bool FilterOfflineUsers(Pair pair, List groups) => ((pair.IsDirectlyPaired && _configService.Current.ShowSyncshellOfflineUsersSeparately) || !_configService.Current.ShowSyncshellOfflineUsersSeparately) && (!pair.IsOneSidedPair || groups.Count != 0) && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused(); - - private static bool FilterOfflineSyncshellUsers(Pair pair) => !pair.IsDirectlyPaired && !pair.IsOnline && !pair.UserPair.OwnPermissions.IsPaused(); - - private Dictionary> BasicSortedDictionary(IEnumerable>> pairs) => pairs.OrderByDescending(u => u.Key.IsVisible).ThenByDescending(u => u.Key.IsOnline).ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase).ToDictionary(u => u.Key, u => u.Value); - - private static ImmutableList ImmutablePairList(IEnumerable>> pairs) => [.. pairs.Select(k => k.Key)]; - - private void GetGroups(Dictionary> allPairs, - Dictionary> filteredPairs, - GroupFullInfoDto group, - out ImmutableList allGroupPairs, - out Dictionary> filteredGroupPairs) + private bool FilterOfflineUsers(PairUiEntry entry) { - allGroupPairs = ImmutablePairList(allPairs - .Where(u => FilterGroupUsers(u.Value, group))); + var groups = entry.DisplayEntry.Groups; + var includeDirect = _configService.Current.ShowSyncshellOfflineUsersSeparately ? entry.IsDirectlyPaired : true; + var includeGroup = !entry.IsOneSided || groups.Count != 0; + return includeDirect && includeGroup && !entry.IsOnline && !entry.SelfPermissions.IsPaused(); + } - filteredGroupPairs = filteredPairs - .Where(u => FilterGroupUsers(u.Value, group) && FilterOnlineOrPausedSelf(u.Key)) - .OrderByDescending(u => u.Key.IsOnline) - .ThenBy(u => - { - if (string.Equals(u.Key.UserData.UID, group.OwnerUID, StringComparison.Ordinal)) return 0; - if (group.GroupPairUserInfos.TryGetValue(u.Key.UserData.UID, out var info)) - { - if (info.IsModerator()) return 1; - if (info.IsPinned()) return 2; - } - return u.Key.IsVisible ? 3 : 4; - }) - .ThenBy(u => AlphabeticalSortKey(u.Key), StringComparer.OrdinalIgnoreCase) - .ToDictionary(k => k.Key, k => k.Value); + private static bool FilterOfflineSyncshellUsers(PairUiEntry entry) => !entry.IsDirectlyPaired && !entry.IsOnline && !entry.SelfPermissions.IsPaused(); + + private ImmutableList SortEntries(IEnumerable entries) + { + return entries + .OrderByDescending(e => e.IsVisible) + .ThenByDescending(e => e.IsOnline) + .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) + .ToImmutableList(); + } + + private ImmutableList SortVisibleEntries(IEnumerable entries) + { + var entryList = entries.ToList(); + return _configService.Current.VisiblePairSortMode switch + { + VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes), + VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes), + VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris), + VisiblePairSortMode.Alphabetical => entryList + .OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) + .ToImmutableList(), + VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), + _ => SortEntries(entryList), + }; + } + + private ImmutableList SortVisibleByMetric(IEnumerable entries, Func selector) + { + return entries + .OrderByDescending(entry => selector(entry) >= 0) + .ThenByDescending(selector) + .ThenByDescending(entry => entry.IsOnline) + .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase) + .ToImmutableList(); + } + + private ImmutableList SortVisibleByPreferred(IEnumerable entries) + { + return entries + .OrderByDescending(entry => entry.IsDirectlyPaired && entry.SelfPermissions.IsSticky()) + .ThenByDescending(entry => entry.IsDirectlyPaired) + .ThenByDescending(entry => entry.IsOnline) + .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase) + .ToImmutableList(); + } + + private ImmutableList SortGroupEntries(IEnumerable entries, GroupFullInfoDto group) + { + return entries + .OrderByDescending(e => e.IsOnline) + .ThenBy(e => GroupSortWeight(e, group)) + .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) + .ToImmutableList(); + } + + private int GroupSortWeight(PairUiEntry entry, GroupFullInfoDto group) + { + if (string.Equals(entry.DisplayEntry.Ident.UserId, group.OwnerUID, StringComparison.Ordinal)) + { + return 0; + } + + if (group.GroupPairUserInfos.TryGetValue(entry.DisplayEntry.Ident.UserId, out var info)) + { + if (info.IsModerator()) return 1; + if (info.IsPinned()) return 2; + } + + return entry.IsVisible ? 3 : 4; + } + + private ImmutableList ResolveGroupEntries( + IReadOnlyDictionary entryLookup, + IReadOnlyDictionary syncshells, + GroupFullInfoDto group, + bool applyFilters) + { + if (!syncshells.TryGetValue(group.Group.GID, out var shell)) + { + return ImmutableList.Empty; + } + + var entries = shell.Users.Keys + .Select(id => entryLookup.TryGetValue(id, out var entry) ? entry : null) + .Where(entry => entry is not null) + .Cast(); + + if (applyFilters && _configService.Current.ShowOfflineUsersSeparately) + { + entries = entries.Where(entry => !FilterOfflineUsers(entry)); + } + + if (applyFilters && _configService.Current.ShowSyncshellOfflineUsersSeparately) + { + entries = entries.Where(entry => !FilterOfflineSyncshellUsers(entry)); + } + + return SortGroupEntries(entries, group); } private string GetServerError() diff --git a/LightlessSync/UI/Components/DrawFolderBase.cs b/LightlessSync/UI/Components/DrawFolderBase.cs index 15d558e..40330c7 100644 --- a/LightlessSync/UI/Components/DrawFolderBase.cs +++ b/LightlessSync/UI/Components/DrawFolderBase.cs @@ -1,9 +1,11 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; -using LightlessSync.PlayerData.Pairs; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; using System.Collections.Immutable; +using LightlessSync.UI; +using LightlessSync.UI.Style; namespace LightlessSync.UI.Components; @@ -11,16 +13,18 @@ public abstract class DrawFolderBase : IDrawFolder { public IImmutableList DrawPairs { get; init; } protected readonly string _id; - protected readonly IImmutableList _allPairs; + protected readonly IImmutableList _allPairs; protected readonly TagHandler _tagHandler; protected readonly UiSharedService _uiSharedService; private float _menuWidth = -1; - public int OnlinePairs => DrawPairs.Count(u => u.Pair.IsOnline); + public int OnlinePairs => DrawPairs.Count(u => u.DisplayEntry.Connection.IsOnline); public int TotalPairs => _allPairs.Count; private bool _wasHovered = false; + private bool _suppressNextRowToggle; + private bool _rowClickArmed; protected DrawFolderBase(string id, IImmutableList drawPairs, - IImmutableList allPairs, TagHandler tagHandler, UiSharedService uiSharedService) + IImmutableList allPairs, TagHandler tagHandler, UiSharedService uiSharedService) { _id = id; DrawPairs = drawPairs; @@ -31,11 +35,14 @@ public abstract class DrawFolderBase : IDrawFolder protected abstract bool RenderIfEmpty { get; } protected abstract bool RenderMenu { get; } + protected virtual bool EnableRowClick => true; public void Draw() { if (!RenderIfEmpty && !DrawPairs.Any()) return; + _suppressNextRowToggle = false; + using var id = ImRaii.PushId("folder_" + _id); var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) @@ -48,7 +55,8 @@ public abstract class DrawFolderBase : IDrawFolder _uiSharedService.IconText(icon); if (ImGui.IsItemClicked()) { - _tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id)); + ToggleFolderOpen(); + SuppressNextRowToggle(); } ImGui.SameLine(); @@ -62,10 +70,41 @@ public abstract class DrawFolderBase : IDrawFolder DrawName(rightSideStart - leftSideEnd); } - _wasHovered = ImGui.IsItemHovered(); + var rowHovered = ImGui.IsItemHovered(); + _wasHovered = rowHovered; + + if (EnableRowClick) + { + if (rowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left) && !_suppressNextRowToggle) + { + _rowClickArmed = true; + } + + if (_rowClickArmed && rowHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + ToggleFolderOpen(); + _rowClickArmed = false; + } + + if (!ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + _rowClickArmed = false; + } + } + else + { + _rowClickArmed = false; + } + + if (_wasHovered) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true); + } color.Dispose(); + _suppressNextRowToggle = false; + ImGui.Separator(); // if opened draw content @@ -110,6 +149,7 @@ public abstract class DrawFolderBase : IDrawFolder ImGui.SameLine(windowEndX - barButtonSize.X); if (_uiSharedService.IconButton(FontAwesomeIcon.EllipsisV)) { + SuppressNextRowToggle(); ImGui.OpenPopup("User Flyout Menu"); } if (ImGui.BeginPopup("User Flyout Menu")) @@ -123,7 +163,16 @@ public abstract class DrawFolderBase : IDrawFolder _menuWidth = 0; } } - return DrawRightSide(rightSideStart); } + + protected void SuppressNextRowToggle() + { + _suppressNextRowToggle = true; + } + + private void ToggleFolderOpen() + { + _tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id)); + } } \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawFolderGroup.cs b/LightlessSync/UI/Components/DrawFolderGroup.cs index 6de9e28..ef9fdfb 100644 --- a/LightlessSync/UI/Components/DrawFolderGroup.cs +++ b/LightlessSync/UI/Components/DrawFolderGroup.cs @@ -5,9 +5,9 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; using LightlessSync.WebAPI; using System.Collections.Immutable; @@ -22,7 +22,7 @@ public class DrawFolderGroup : DrawFolderBase private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; public DrawFolderGroup(string id, GroupFullInfoDto groupFullInfoDto, ApiController apiController, - IImmutableList drawPairs, IImmutableList allPairs, TagHandler tagHandler, IdDisplayHandler idDisplayHandler, + IImmutableList drawPairs, IImmutableList allPairs, TagHandler tagHandler, IdDisplayHandler idDisplayHandler, LightlessMediator lightlessMediator, UiSharedService uiSharedService, SelectTagForSyncshellUi selectTagForSyncshellUi) : base(id, drawPairs, allPairs, tagHandler, uiSharedService) { @@ -35,6 +35,7 @@ public class DrawFolderGroup : DrawFolderBase protected override bool RenderIfEmpty => true; protected override bool RenderMenu => true; + protected override bool EnableRowClick => false; private bool IsModerator => IsOwner || _groupFullInfoDto.GroupUserInfo.IsModerator(); private bool IsOwner => string.Equals(_groupFullInfoDto.OwnerUID, _apiController.UID, StringComparison.Ordinal); private bool IsPinned => _groupFullInfoDto.GroupUserInfo.IsPinned(); @@ -87,6 +88,13 @@ public class DrawFolderGroup : DrawFolderBase ImGui.Separator(); ImGui.TextUnformatted("General Syncshell Actions"); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile", menuWidth, true)) + { + ImGui.CloseCurrentPopup(); + _lightlessMediator.Publish(new GroupProfileOpenStandaloneMessage(_groupFullInfoDto)); + } + UiSharedService.AttachToolTip("Opens the profile for this syncshell in a new window."); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Copy, "Copy ID", menuWidth, true)) { ImGui.CloseCurrentPopup(); @@ -160,6 +168,14 @@ public class DrawFolderGroup : DrawFolderBase { ImGui.Separator(); ImGui.TextUnformatted("Syncshell Admin Functions"); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserEdit, "Open Profile Editor", menuWidth, true)) + { + ImGui.CloseCurrentPopup(); + _lightlessMediator.Publish(new OpenGroupProfileEditorMessage(_groupFullInfoDto)); + } + UiSharedService.AttachToolTip("Open the syncshell profile editor."); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Cog, "Open Admin Panel", menuWidth, true)) { ImGui.CloseCurrentPopup(); @@ -244,6 +260,7 @@ public class DrawFolderGroup : DrawFolderBase ImGui.SameLine(); if (_uiSharedService.IconButton(pauseIcon)) { + SuppressNextRowToggle(); var perm = _groupFullInfoDto.GroupUserPermissions; perm.SetPaused(!perm.IsPaused()); _ = _apiController.GroupChangeIndividualPermissionState(new GroupPairUserPermissionDto(_groupFullInfoDto.Group, new(_apiController.UID), perm)); diff --git a/LightlessSync/UI/Components/DrawFolderTag.cs b/LightlessSync/UI/Components/DrawFolderTag.cs index 0c114e1..dcba0d4 100644 --- a/LightlessSync/UI/Components/DrawFolderTag.cs +++ b/LightlessSync/UI/Components/DrawFolderTag.cs @@ -1,11 +1,18 @@ -using Dalamud.Bindings.ImGui; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; -using LightlessSync.PlayerData.Pairs; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.Mediator; +using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; using LightlessSync.WebAPI; -using System.Collections.Immutable; namespace LightlessSync.UI.Components; @@ -14,14 +21,30 @@ public class DrawFolderTag : DrawFolderBase private readonly ApiController _apiController; private readonly SelectPairForTagUi _selectPairForTagUi; private readonly RenamePairTagUi _renameTagUi; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly LightlessConfigService _configService; + private readonly LightlessMediator _mediator; - public DrawFolderTag(string id, IImmutableList drawPairs, IImmutableList allPairs, - TagHandler tagHandler, ApiController apiController, SelectPairForTagUi selectPairForTagUi, RenamePairTagUi renameTagUi, UiSharedService uiSharedService) + public DrawFolderTag( + string id, + IImmutableList drawPairs, + IImmutableList allPairs, + TagHandler tagHandler, + ApiController apiController, + SelectPairForTagUi selectPairForTagUi, + RenamePairTagUi renameTagUi, + UiSharedService uiSharedService, + ServerConfigurationManager serverConfigurationManager, + LightlessConfigService configService, + LightlessMediator mediator) : base(id, drawPairs, allPairs, tagHandler, uiSharedService) { _apiController = apiController; _selectPairForTagUi = selectPairForTagUi; _renameTagUi = renameTagUi; + _serverConfigurationManager = serverConfigurationManager; + _configService = configService; + _mediator = mediator; } protected override bool RenderIfEmpty => _id switch @@ -86,15 +109,18 @@ public class DrawFolderTag : DrawFolderBase if (RenderCount) { - using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, ImGui.GetStyle().ItemSpacing with { X = ImGui.GetStyle().ItemSpacing.X / 2f })) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, + ImGui.GetStyle().ItemSpacing with { X = ImGui.GetStyle().ItemSpacing.X / 2f })) { ImGui.SameLine(); ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]"); + ImGui.TextUnformatted($"[{OnlinePairs}]"); } - UiSharedService.AttachToolTip(OnlinePairs + " online" + Environment.NewLine + TotalPairs + " total"); + + UiSharedService.AttachToolTip($"{OnlinePairs} online{Environment.NewLine}{TotalPairs} total"); } + ImGui.SameLine(); return ImGui.GetCursorPosX(); } @@ -102,19 +128,24 @@ public class DrawFolderTag : DrawFolderBase protected override void DrawMenu(float menuWidth) { ImGui.TextUnformatted("Group Menu"); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Select Pairs", menuWidth, isInPopup: true)) + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Select Pairs", menuWidth, true)) { _selectPairForTagUi.Open(_id); } - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Edit, "Rename Pair Group", menuWidth, isInPopup: true)) + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Edit, "Rename Pair Group", menuWidth, true)) { _renameTagUi.Open(_id); } - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Pair Group", menuWidth, isInPopup: true) && UiSharedService.CtrlPressed()) + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Pair Group", menuWidth, true) && + UiSharedService.CtrlPressed()) { _tagHandler.RemovePairTag(_id); } - UiSharedService.AttachToolTip("Hold CTRL to remove this Group permanently." + Environment.NewLine + + + UiSharedService.AttachToolTip( + "Hold CTRL to remove this Group permanently." + Environment.NewLine + "Note: this will not unpair with users in this Group."); } @@ -122,7 +153,7 @@ public class DrawFolderTag : DrawFolderBase { ImGui.AlignTextToFramePadding(); - string name = _id switch + var name = _id switch { TagHandler.CustomUnpairedTag => "One-sided Individual Pairs", TagHandler.CustomOnlineTag => "Online / Paused by you", @@ -138,16 +169,25 @@ public class DrawFolderTag : DrawFolderBase protected override float DrawRightSide(float currentRightSideX) { - if (!RenderPause) return currentRightSideX; - - var allArePaused = _allPairs.All(pair => pair.UserPair!.OwnPermissions.IsPaused()); - var pauseButton = allArePaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; - var pauseButtonX = _uiSharedService.GetIconButtonSize(pauseButton).X; - - var buttonPauseOffset = currentRightSideX - pauseButtonX; - ImGui.SameLine(buttonPauseOffset); - if (_uiSharedService.IconButton(pauseButton)) + if (_id == TagHandler.CustomVisibleTag) { + return DrawVisibleFilter(currentRightSideX); + } + + if (!RenderPause) + { + return currentRightSideX; + } + + var allArePaused = _allPairs.All(entry => entry.SelfPermissions.IsPaused()); + var pauseIcon = allArePaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; + var pauseButtonSize = _uiSharedService.GetIconButtonSize(pauseIcon); + + var buttonPauseOffset = currentRightSideX - pauseButtonSize.X; + ImGui.SameLine(buttonPauseOffset); + if (_uiSharedService.IconButton(pauseIcon)) + { + SuppressNextRowToggle(); if (allArePaused) { ResumeAllPairs(_allPairs); @@ -157,39 +197,89 @@ public class DrawFolderTag : DrawFolderBase PauseRemainingPairs(_allPairs); } } - if (allArePaused) - { - UiSharedService.AttachToolTip($"Resume pairing with all pairs in {_id}"); - } - else - { - UiSharedService.AttachToolTip($"Pause pairing with all pairs in {_id}"); - } + + UiSharedService.AttachToolTip(allArePaused + ? $"Resume pairing with all pairs in {_id}" + : $"Pause pairing with all pairs in {_id}"); return currentRightSideX; } - private void PauseRemainingPairs(IEnumerable availablePairs) + private void PauseRemainingPairs(IEnumerable entries) { - _ = _apiController.SetBulkPermissions(new(availablePairs - .ToDictionary(g => g.UserData.UID, g => - { - var perm = g.UserPair.OwnPermissions; - perm.SetPaused(paused: true); - return perm; - }, StringComparer.Ordinal), new(StringComparer.Ordinal))) + _ = _apiController.SetBulkPermissions(new( + entries.ToDictionary(entry => entry.DisplayEntry.User.UID, entry => + { + var permissions = entry.SelfPermissions; + permissions.SetPaused(true); + return permissions; + }, StringComparer.Ordinal), + new(StringComparer.Ordinal))) .ConfigureAwait(false); } - private void ResumeAllPairs(IEnumerable availablePairs) + private void ResumeAllPairs(IEnumerable entries) { - _ = _apiController.SetBulkPermissions(new(availablePairs - .ToDictionary(g => g.UserData.UID, g => - { - var perm = g.UserPair.OwnPermissions; - perm.SetPaused(paused: false); - return perm; - }, StringComparer.Ordinal), new(StringComparer.Ordinal))) + _ = _apiController.SetBulkPermissions(new( + entries.ToDictionary(entry => entry.DisplayEntry.User.UID, entry => + { + var permissions = entry.SelfPermissions; + permissions.SetPaused(false); + return permissions; + }, StringComparer.Ordinal), + new(StringComparer.Ordinal))) .ConfigureAwait(false); } + + private float DrawVisibleFilter(float currentRightSideX) + { + var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Filter); + var spacingX = ImGui.GetStyle().ItemSpacing.X; + var buttonStart = currentRightSideX - buttonSize.X; + + ImGui.SameLine(buttonStart); + if (_uiSharedService.IconButton(FontAwesomeIcon.Filter)) + { + SuppressNextRowToggle(); + ImGui.OpenPopup($"visible-filter-{_id}"); + } + + UiSharedService.AttachToolTip("Adjust how visible pairs are ordered."); + + if (ImGui.BeginPopup($"visible-filter-{_id}")) + { + ImGui.TextUnformatted("Visible Pair Ordering"); + ImGui.Separator(); + + foreach (VisiblePairSortMode mode in Enum.GetValues()) + { + var selected = _configService.Current.VisiblePairSortMode == mode; + if (ImGui.MenuItem(GetSortLabel(mode), string.Empty, selected)) + { + if (!selected) + { + _configService.Current.VisiblePairSortMode = mode; + _configService.Save(); + _mediator.Publish(new RefreshUiMessage()); + } + + ImGui.CloseCurrentPopup(); + } + } + + ImGui.EndPopup(); + } + + return buttonStart - spacingX; + } + + private static string GetSortLabel(VisiblePairSortMode mode) => mode switch + { + VisiblePairSortMode.Alphabetical => "Alphabetical", + VisiblePairSortMode.VramUsage => "VRAM usage (descending)", + VisiblePairSortMode.EffectiveVramUsage => "Effective VRAM usage (descending)", + VisiblePairSortMode.TriangleCount => "Triangle count (descending)", + VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", + _ => "Default", + }; } \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs index 1bb3d79..72063f2 100644 --- a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs +++ b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs @@ -1,9 +1,11 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility.Raii; +using LightlessSync.UI; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Style; using LightlessSync.UI.Models; using LightlessSync.WebAPI; using System.Collections.Immutable; @@ -22,6 +24,7 @@ public class DrawGroupedGroupFolder : IDrawFolder private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private bool _wasHovered = false; private float _menuWidth; + private bool _rowClickArmed; public IImmutableList DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count(); @@ -48,7 +51,9 @@ public class DrawGroupedGroupFolder : IDrawFolder using var id = ImRaii.PushId(_id); var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); - using (ImRaii.Child("folder__" + _id, new Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) + var allowRowClick = string.IsNullOrEmpty(_tag); + var suppressRowToggle = false; + using (ImRaii.Child("folder__" + _id, new System.Numerics.Vector2(UiSharedService.GetWindowContentRegionWidth() - ImGui.GetCursorPosX(), ImGui.GetFrameHeight()))) { ImGui.Dummy(new Vector2(0f, ImGui.GetFrameHeight())); using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(0f, 0f))) @@ -61,6 +66,7 @@ public class DrawGroupedGroupFolder : IDrawFolder if (ImGui.IsItemClicked()) { _tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id)); + suppressRowToggle = true; } ImGui.SameLine(); @@ -92,7 +98,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.SameLine(); DrawPauseButton(); ImGui.SameLine(); - DrawMenu(); + DrawMenu(ref suppressRowToggle); } else { ImGui.TextUnformatted("All Syncshells"); @@ -102,7 +108,36 @@ public class DrawGroupedGroupFolder : IDrawFolder } } color.Dispose(); - _wasHovered = ImGui.IsItemHovered(); + var rowHovered = ImGui.IsItemHovered(); + _wasHovered = rowHovered; + + if (allowRowClick) + { + if (rowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left) && !suppressRowToggle) + { + _rowClickArmed = true; + } + + if (_rowClickArmed && rowHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left)) + { + _tagHandler.SetTagOpen(_id, !_tagHandler.IsTagOpen(_id)); + _rowClickArmed = false; + } + + if (!ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + _rowClickArmed = false; + } + } + else + { + _rowClickArmed = false; + } + + if (_wasHovered) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true); + } ImGui.Separator(); @@ -154,7 +189,7 @@ public class DrawGroupedGroupFolder : IDrawFolder } } - protected void DrawMenu() + protected void DrawMenu(ref bool suppressRowToggle) { var barButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.EllipsisV); var windowEndX = ImGui.GetWindowContentRegionMin().X + UiSharedService.GetWindowContentRegionWidth(); @@ -162,6 +197,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.SameLine(windowEndX - barButtonSize.X); if (_uiSharedService.IconButton(FontAwesomeIcon.EllipsisV)) { + suppressRowToggle = true; ImGui.OpenPopup("User Flyout Menu"); } if (ImGui.BeginPopup("User Flyout Menu")) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 4c4c1d4..0c8b649 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -12,11 +12,16 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; +using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; +using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Text; +using LightlessSync.UI; namespace LightlessSync.UI.Components; @@ -27,29 +32,41 @@ public class DrawUserPair protected readonly LightlessMediator _mediator; protected readonly List _syncedGroups; private readonly GroupFullInfoDto? _currentGroup; - protected Pair _pair; + protected Pair? _pair; + private PairUiEntry _uiEntry; + protected PairDisplayEntry _displayEntry; private readonly string _id; private readonly SelectTagForPairUi _selectTagForPairUi; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; private readonly CharaDataManager _charaDataManager; + private readonly PairLedger _pairLedger; private float _menuWidth = -1; private bool _wasHovered = false; private TooltipSnapshot _tooltipSnapshot = TooltipSnapshot.Empty; private string _cachedTooltip = string.Empty; - public DrawUserPair(string id, Pair entry, List syncedGroups, + public DrawUserPair( + string id, + PairUiEntry uiEntry, + Pair? legacyPair, GroupFullInfoDto? currentGroup, - ApiController apiController, IdDisplayHandler uIDDisplayHandler, - LightlessMediator lightlessMediator, SelectTagForPairUi selectTagForPairUi, + ApiController apiController, + IdDisplayHandler uIDDisplayHandler, + LightlessMediator lightlessMediator, + SelectTagForPairUi selectTagForPairUi, ServerConfigurationManager serverConfigurationManager, - UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, - CharaDataManager charaDataManager) + UiSharedService uiSharedService, + PlayerPerformanceConfigService performanceConfigService, + CharaDataManager charaDataManager, + PairLedger pairLedger) { _id = id; - _pair = entry; - _syncedGroups = syncedGroups; + _uiEntry = uiEntry; + _displayEntry = uiEntry.DisplayEntry; + _pair = legacyPair ?? throw new ArgumentNullException(nameof(legacyPair)); + _syncedGroups = uiEntry.DisplayEntry.Groups.ToList(); _currentGroup = currentGroup; _apiController = apiController; _displayHandler = uIDDisplayHandler; @@ -59,6 +76,18 @@ public class DrawUserPair _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; _charaDataManager = charaDataManager; + _pairLedger = pairLedger; + } + + public PairDisplayEntry DisplayEntry => _displayEntry; + public PairUiEntry UiEntry => _uiEntry; + + public void UpdateDisplayEntry(PairUiEntry entry) + { + _uiEntry = entry; + _displayEntry = entry.DisplayEntry; + _syncedGroups.Clear(); + _syncedGroups.AddRange(entry.DisplayEntry.Groups); } public Pair Pair => _pair; @@ -77,6 +106,10 @@ public class DrawUserPair DrawName(posX, rightSide); } _wasHovered = ImGui.IsItemHovered(); + if (_wasHovered) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), spanFullWidth: true); + } color.Dispose(); } @@ -103,7 +136,7 @@ public class DrawUserPair if (_uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Cycle pause state", _menuWidth, true)) { - _ = _apiController.CyclePauseAsync(_pair.UserData); + _ = _apiController.CyclePauseAsync(_pair); ImGui.CloseCurrentPopup(); } ImGui.Separator(); @@ -313,6 +346,7 @@ public class DrawUserPair _pair.PlayerName ?? string.Empty, _pair.LastAppliedDataBytes, _pair.LastAppliedApproximateVRAMBytes, + _pair.LastAppliedApproximateEffectiveVRAMBytes, _pair.LastAppliedDataTris, _pair.IsPaired, groupDisplays is null ? ImmutableArray.Empty : ImmutableArray.CreateRange(groupDisplays)); @@ -381,7 +415,14 @@ public class DrawUserPair { builder.Append(Environment.NewLine); builder.Append("Approx. VRAM Usage: "); - builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true)); + var originalText = UiSharedService.ByteToString(snapshot.LastAppliedApproximateVRAMBytes, true); + builder.Append(originalText); + if (snapshot.LastAppliedApproximateEffectiveVRAMBytes >= 0) + { + builder.Append(" (Effective: "); + builder.Append(UiSharedService.ByteToString(snapshot.LastAppliedApproximateEffectiveVRAMBytes, true)); + builder.Append(')'); + } } if (snapshot.LastAppliedDataTris >= 0) @@ -420,12 +461,13 @@ public class DrawUserPair string PlayerName, long LastAppliedDataBytes, long LastAppliedApproximateVRAMBytes, + long LastAppliedApproximateEffectiveVRAMBytes, long LastAppliedDataTris, bool IsPaired, ImmutableArray GroupDisplays) { public static TooltipSnapshot Empty { get; } = - new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, false, ImmutableArray.Empty); + new(false, false, false, IndividualPairStatus.None, string.Empty, string.Empty, -1, -1, -1, -1, false, ImmutableArray.Empty); } private void DrawPairedClientMenu() @@ -647,7 +689,13 @@ public class DrawUserPair private void DrawSyncshellMenu(GroupFullInfoDto group, bool selfIsOwner, bool selfIsModerator, bool userIsPinned, bool userIsModerator) { - if (selfIsOwner || ((selfIsModerator) && (!userIsModerator))) + var showModeratorActions = selfIsOwner || (selfIsModerator && !userIsModerator); + var showOwnerActions = selfIsOwner; + + if (showModeratorActions || showOwnerActions) + ImGui.Separator(); + + if (showModeratorActions) { ImGui.TextUnformatted("Syncshell Moderator Functions"); var pinText = userIsPinned ? "Unpin user" : "Pin user"; @@ -683,7 +731,7 @@ public class DrawUserPair ImGui.Separator(); } - if (selfIsOwner) + if (showOwnerActions) { ImGui.TextUnformatted("Syncshell Owner Functions"); string modText = userIsModerator ? "Demod user" : "Mod user"; diff --git a/LightlessSync/UI/Components/Popup/BanUserPopupHandler.cs b/LightlessSync/UI/Components/Popup/BanUserPopupHandler.cs index b1cb3f8..ee80074 100644 --- a/LightlessSync/UI/Components/Popup/BanUserPopupHandler.cs +++ b/LightlessSync/UI/Components/Popup/BanUserPopupHandler.cs @@ -1,6 +1,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI; @@ -12,14 +13,16 @@ public class BanUserPopupHandler : IPopupHandler { private readonly ApiController _apiController; private readonly UiSharedService _uiSharedService; + private readonly PairFactory _pairFactory; private string _banReason = string.Empty; private GroupFullInfoDto _group = null!; private Pair _reportedPair = null!; - public BanUserPopupHandler(ApiController apiController, UiSharedService uiSharedService) + public BanUserPopupHandler(ApiController apiController, UiSharedService uiSharedService, PairFactory pairFactory) { _apiController = apiController; _uiSharedService = uiSharedService; + _pairFactory = pairFactory; } public Vector2 PopupSize => new(500, 250); @@ -43,7 +46,7 @@ public class BanUserPopupHandler : IPopupHandler public void Open(OpenBanUserPopupMessage message) { - _reportedPair = message.PairToBan; + _reportedPair = _pairFactory.Create(message.PairToBan.UniqueIdent) ?? message.PairToBan; _group = message.GroupFullInfoDto; _banReason = string.Empty; } diff --git a/LightlessSync/UI/Components/SelectPairForTagUi.cs b/LightlessSync/UI/Components/SelectPairForTagUi.cs index 89db40e..a2ae7d1 100644 --- a/LightlessSync/UI/Components/SelectPairForTagUi.cs +++ b/LightlessSync/UI/Components/SelectPairForTagUi.cs @@ -23,7 +23,7 @@ public class SelectPairForTagUi _uidDisplayHandler = uidDisplayHandler; } - public void Draw(List pairs) + public void Draw(IReadOnlyList pairs) { var workHeight = ImGui.GetMainViewport().WorkSize.Y / ImGuiHelpers.GlobalScale; var minSize = new Vector2(300, workHeight < 400 ? workHeight : 400) * ImGuiHelpers.GlobalScale; diff --git a/LightlessSync/UI/Components/SelectSyncshellForTagUi.cs b/LightlessSync/UI/Components/SelectSyncshellForTagUi.cs index 62dd1d6..63e48e0 100644 --- a/LightlessSync/UI/Components/SelectSyncshellForTagUi.cs +++ b/LightlessSync/UI/Components/SelectSyncshellForTagUi.cs @@ -21,7 +21,7 @@ public class SelectSyncshellForTagUi _tagHandler = tagHandler; } - public void Draw(List groups) + public void Draw(IReadOnlyCollection groups) { var workHeight = ImGui.GetMainViewport().WorkSize.Y / ImGuiHelpers.GlobalScale; var minSize = new Vector2(300, workHeight < 400 ? workHeight : 400) * ImGuiHelpers.GlobalScale; diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 5b750f3..725e004 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -1,6 +1,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; +using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Enum; @@ -9,41 +10,94 @@ using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.TextureCompression; using LightlessSync.Utils; +using Penumbra.Api.Enums; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; using System.Numerics; +using System.Threading; +using System.Threading.Tasks; namespace LightlessSync.UI; public class DataAnalysisUi : WindowMediatorSubscriberBase { + private const float MinTextureFilterPaneWidth = 305f; + private const float MaxTextureFilterPaneWidth = 405f; + private const float MinTextureDetailPaneWidth = 580f; + private const float MaxTextureDetailPaneWidth = 720f; + private const float SelectedFilePanelLogicalHeight = 90f; + private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); + private readonly CharacterAnalyzer _characterAnalyzer; - private readonly Progress<(string, int)> _conversionProgress = new(); + private readonly Progress _conversionProgress = new(); private readonly IpcManager _ipcManager; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _playerPerformanceConfig; private readonly TransientResourceManager _transientResourceManager; private readonly TransientConfigService _transientConfigService; - private readonly Dictionary _texturesToConvert = new(StringComparer.Ordinal); + private readonly TextureCompressionService _textureCompressionService; + private readonly TextureMetadataHelper _textureMetadataHelper; + + private readonly List _textureRows = new(); + private readonly Dictionary _textureSelections = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _selectedTextureKeys = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _texturePreviews = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _textureWorkspaceTabs = new(); + private readonly List _storedPathsToRemove = []; + private readonly Dictionary _filePathResolve = []; + private Dictionary>? _cachedAnalysis; private CancellationTokenSource _conversionCancellationTokenSource = new(); - private string _conversionCurrentFileName = string.Empty; - private int _conversionCurrentFileProgress = 0; + private CancellationTokenSource _transientRecordCts = new(); + private Task? _conversionTask; - private bool _enableBc7ConversionMode = false; - private bool _hasUpdate = false; - private bool _modalOpen = false; + private TextureConversionProgress? _lastConversionProgress; + + private float _textureFilterPaneWidth = 320f; + private float _textureDetailPaneWidth = 360f; + private float _textureDetailHeight = 360f; + private float _texturePreviewSize = 360f; + + private string _conversionCurrentFileName = string.Empty; private string _selectedFileTypeTab = string.Empty; private string _selectedHash = string.Empty; - private ObjectKind _selectedObjectTab; + private string _textureSearch = string.Empty; + private string _textureSlotFilter = "All"; + private string _selectedTextureKey = string.Empty; + private string _selectedStoredCharacter = string.Empty; + private string _selectedJobEntry = string.Empty; + private string _filterGamePath = string.Empty; + private string _filterFilePath = string.Empty; + + private int _conversionCurrentFileProgress = 0; + private int _conversionTotalJobs; + + private bool _hasUpdate = false; + private bool _modalOpen = false; private bool _showModal = false; - private CancellationTokenSource _transientRecordCts = new(); + private bool _textureRowsDirty = true; + private bool _conversionFailed; + private bool _showAlreadyAddedTransients = false; + private bool _acknowledgeReview = false; + + private ObjectKind _selectedObjectTab; + + private TextureUsageCategory? _textureCategoryFilter = null; + private TextureMapKind? _textureMapFilter = null; + private TextureCompressionTarget? _textureTargetFilter = null; public DataAnalysisUi(ILogger logger, LightlessMediator mediator, CharacterAnalyzer characterAnalyzer, IpcManager ipcManager, PerformanceCollectorService performanceCollectorService, UiSharedService uiSharedService, PlayerPerformanceConfigService playerPerformanceConfig, TransientResourceManager transientResourceManager, - TransientConfigService transientConfigService) + TransientConfigService transientConfigService, TextureCompressionService textureCompressionService, + TextureMetadataHelper textureMetadataHelper) : base(logger, mediator, "Lightless Character Data Analysis", performanceCollectorService) { _characterAnalyzer = characterAnalyzer; @@ -52,6 +106,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _playerPerformanceConfig = playerPerformanceConfig; _transientResourceManager = transientResourceManager; _transientConfigService = transientConfigService; + _textureCompressionService = textureCompressionService; + _textureMetadataHelper = textureMetadataHelper; Mediator.Subscribe(this, (_) => { _hasUpdate = true; @@ -60,8 +116,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { MinimumSize = new() { - X = 800, - Y = 600 + X = 1650, + Y = 1000 }, MaximumSize = new() { @@ -75,91 +131,139 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase protected override void DrawInternal() { - if (_conversionTask != null && !_conversionTask.IsCompleted) + HandleConversionModal(); + RefreshAnalysisCache(); + DrawContentTabs(); + } + + private void HandleConversionModal() + { + if (_conversionTask == null) { - _showModal = true; - if (ImGui.BeginPopupModal("BC7 Conversion in Progress")) - { - ImGui.TextUnformatted("BC7 Conversion in progress: " + _conversionCurrentFileProgress + "/" + _texturesToConvert.Count); - UiSharedService.TextWrapped("Current file: " + _conversionCurrentFileName); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) - { - _conversionCancellationTokenSource.Cancel(); - } - UiSharedService.SetScaledWindowSize(500); - ImGui.EndPopup(); - } - else - { - _modalOpen = false; - } + return; } - else if (_conversionTask != null && _conversionTask.IsCompleted && _texturesToConvert.Count > 0) + + if (_conversionTask.IsCompleted) + { + ResetConversionModalState(); + return; + } + + _showModal = true; + if (ImGui.BeginPopupModal("Texture Compression in Progress", ImGuiWindowFlags.AlwaysAutoResize)) + { + DrawConversionModalContent(); + ImGui.EndPopup(); + } + else { - _conversionTask = null; - _texturesToConvert.Clear(); - _showModal = false; _modalOpen = false; - _enableBc7ConversionMode = false; } if (_showModal && !_modalOpen) { - ImGui.OpenPopup("BC7 Conversion in Progress"); + ImGui.OpenPopup("Texture Compression in Progress"); _modalOpen = true; } - - if (_hasUpdate) - { - _cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone(); - _hasUpdate = false; - } - - using var tabBar = ImRaii.TabBar("analysisRecordingTabBar"); - using (var tabItem = ImRaii.TabItem("Analysis")) - { - if (tabItem) - { - using var id = ImRaii.PushId("analysis"); - DrawAnalysis(); - } - } - using (var tabItem = ImRaii.TabItem("Transient Files")) - { - if (tabItem) - { - using var tabbar = ImRaii.TabBar("transientData"); - - using (var transientData = ImRaii.TabItem("Stored Transient File Data")) - { - using var id = ImRaii.PushId("data"); - - if (transientData) - { - DrawStoredData(); - } - } - using (var transientRecord = ImRaii.TabItem("Record Transient Data")) - { - using var id = ImRaii.PushId("recording"); - - if (transientRecord) - { - DrawRecording(); - } - } - } - } } - private bool _showAlreadyAddedTransients = false; - private bool _acknowledgeReview = false; - private string _selectedStoredCharacter = string.Empty; - private string _selectedJobEntry = string.Empty; - private readonly List _storedPathsToRemove = []; - private readonly Dictionary _filePathResolve = []; - private string _filterGamePath = string.Empty; - private string _filterFilePath = string.Empty; + private void DrawConversionModalContent() + { + var progress = _lastConversionProgress; + var total = progress?.Total ?? Math.Max(_conversionTotalJobs, 1); + var completed = progress != null + ? Math.Min(progress.Completed + 1, total) + : _conversionCurrentFileProgress; + var currentLabel = !string.IsNullOrEmpty(_conversionCurrentFileName) + ? _conversionCurrentFileName + : "Preparing..."; + + ImGui.TextUnformatted($"Compressing textures ({completed}/{total})"); + UiSharedService.TextWrapped("Current file: " + currentLabel); + + if (_conversionFailed) + { + UiSharedService.ColorText("Conversion encountered errors. Please review the log for details.", ImGuiColors.DalamudRed); + } + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.StopCircle, "Cancel conversion")) + { + _conversionCancellationTokenSource.Cancel(); + } + + UiSharedService.SetScaledWindowSize(520); + } + + private void ResetConversionModalState() + { + _conversionTask = null; + _showModal = false; + _modalOpen = false; + _lastConversionProgress = null; + _conversionCurrentFileName = string.Empty; + _conversionCurrentFileProgress = 0; + _conversionTotalJobs = 0; + } + + private void RefreshAnalysisCache() + { + if (!_hasUpdate) + { + return; + } + + _cachedAnalysis = _characterAnalyzer.LastAnalysis.DeepClone(); + _hasUpdate = false; + _textureRowsDirty = true; + } + + private void DrawContentTabs() + { + using var tabBar = ImRaii.TabBar("analysisRecordingTabBar"); + DrawAnalysisTab(); + DrawTransientFilesTab(); + } + + private void DrawAnalysisTab() + { + using var tabItem = ImRaii.TabItem("Analysis"); + if (!tabItem) + { + return; + } + + using var id = ImRaii.PushId("analysis"); + DrawAnalysis(); + } + + private void DrawTransientFilesTab() + { + using var tabItem = ImRaii.TabItem("Transient Files"); + if (!tabItem) + { + return; + } + + using var tabbar = ImRaii.TabBar("transientData"); + + using (var transientData = ImRaii.TabItem("Stored Transient File Data")) + { + using var id = ImRaii.PushId("data"); + if (transientData) + { + DrawStoredData(); + } + } + + using (var transientRecord = ImRaii.TabItem("Record Transient Data")) + { + using var id = ImRaii.PushId("recording"); + if (transientRecord) + { + DrawRecording(); + } + } + } private void DrawStoredData() { @@ -176,191 +280,258 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var config = _transientConfigService.Current.TransientConfigs; Vector2 availableContentRegion = Vector2.Zero; - using (ImRaii.Group()) + DrawCharacterColumn(); + + ImGui.SameLine(); + + bool selectedData = config.TryGetValue(_selectedStoredCharacter, out var transientStorage) && transientStorage != null; + DrawJobColumn(); + + ImGui.SameLine(); + DrawAttachedFilesColumn(); + + return; + + void DrawCharacterColumn() { - ImGui.TextUnformatted("Character"); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(3); - availableContentRegion = ImGui.GetContentRegionAvail(); - using (ImRaii.ListBox("##characters", new Vector2(200, availableContentRegion.Y))) + using (ImRaii.Group()) { - foreach (var entry in config) + ImGui.TextUnformatted("Character"); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(3); + availableContentRegion = ImGui.GetContentRegionAvail(); + using (ImRaii.ListBox("##characters", new Vector2(200, availableContentRegion.Y))) { - var name = entry.Key.Split("_"); - if (!_uiSharedService.WorldData.TryGetValue(ushort.Parse(name[1]), out var worldname)) + foreach (var entry in config) { - continue; - } - if (ImGui.Selectable(name[0] + " (" + worldname + ")", string.Equals(_selectedStoredCharacter, entry.Key, StringComparison.Ordinal))) - { - _selectedStoredCharacter = entry.Key; - _selectedJobEntry = string.Empty; - _storedPathsToRemove.Clear(); - _filePathResolve.Clear(); - _filterFilePath = string.Empty; - _filterGamePath = string.Empty; + var name = entry.Key.Split("_"); + if (!_uiSharedService.WorldData.TryGetValue(ushort.Parse(name[1]), out var worldname)) + { + continue; + } + + bool isSelected = string.Equals(_selectedStoredCharacter, entry.Key, StringComparison.Ordinal); + if (ImGui.Selectable(name[0] + " (" + worldname + ")", isSelected)) + { + _selectedStoredCharacter = entry.Key; + _selectedJobEntry = string.Empty; + ResetSelectionFilters(); + } } } } } - ImGui.SameLine(); - bool selectedData = config.TryGetValue(_selectedStoredCharacter, out var transientStorage) && transientStorage != null; - using (ImRaii.Group()) + + void DrawJobColumn() { - ImGui.TextUnformatted("Job"); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(3); - using (ImRaii.ListBox("##data", new Vector2(150, availableContentRegion.Y))) + using (ImRaii.Group()) { - if (selectedData) + ImGui.TextUnformatted("Job"); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(3); + using (ImRaii.ListBox("##data", new Vector2(150, availableContentRegion.Y))) { + if (!selectedData) + { + return; + } + if (ImGui.Selectable("All Jobs", string.Equals(_selectedJobEntry, "alljobs", StringComparison.Ordinal))) { _selectedJobEntry = "alljobs"; } + foreach (var job in transientStorage!.JobSpecificCache) { - if (!_uiSharedService.JobData.TryGetValue(job.Key, out var jobName)) continue; + if (!_uiSharedService.JobData.TryGetValue(job.Key, out var jobName)) + { + continue; + } + if (ImGui.Selectable(jobName, string.Equals(_selectedJobEntry, job.Key.ToString(), StringComparison.Ordinal))) { _selectedJobEntry = job.Key.ToString(); + ResetSelectionFilters(); + } + } + } + } + } + + void DrawAttachedFilesColumn() + { + using (ImRaii.Group()) + { + var selectedList = string.Equals(_selectedJobEntry, "alljobs", StringComparison.Ordinal) + ? config[_selectedStoredCharacter].GlobalPersistentCache + : (string.IsNullOrEmpty(_selectedJobEntry) ? [] : config[_selectedStoredCharacter].JobSpecificCache[uint.Parse(_selectedJobEntry)]); + ImGui.TextUnformatted($"Attached Files (Total Files: {selectedList.Count})"); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(3); + using (ImRaii.Disabled(string.IsNullOrEmpty(_selectedJobEntry))) + { + var restContent = availableContentRegion.X - ImGui.GetCursorPosX(); + using var group = ImRaii.Group(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Resolve Game Paths to used File Paths")) + { + _ = Task.Run(async () => + { + if (!_ipcManager.Penumbra.APIAvailable) + { + return; + } + + var paths = selectedList.ToArray(); + var (forward, _) = await _ipcManager.Penumbra.ResolvePathsAsync(paths, Array.Empty()).ConfigureAwait(false); + for (int i = 0; i < paths.Length && i < forward.Length; i++) + { + var result = forward[i]; + if (string.IsNullOrEmpty(result)) + { + continue; + } + + if (!_filePathResolve.TryAdd(paths[i], result)) + { + _filePathResolve[paths[i]] = result; + } + } + }); + } + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Eraser, "Clear Game Path File Resolves")) + { + _filePathResolve.Clear(); + } + ImGui.SameLine(); + using (ImRaii.Disabled(!_storedPathsToRemove.Any())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete Selected Game Paths")) + { + foreach (var entry in _storedPathsToRemove) + { + config[_selectedStoredCharacter].GlobalPersistentCache.Remove(entry); + foreach (var job in config[_selectedStoredCharacter].JobSpecificCache) + { + job.Value.Remove(entry); + } + } + _storedPathsToRemove.Clear(); - _filePathResolve.Clear(); + _transientConfigService.Save(); + _transientResourceManager.RebuildSemiTransientResources(); _filterFilePath = string.Empty; _filterGamePath = string.Empty; } } + ImGui.SameLine(); + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear ALL Game Paths")) + { + selectedList.Clear(); + _transientConfigService.Save(); + _transientResourceManager.RebuildSemiTransientResources(); + _filterFilePath = string.Empty; + _filterGamePath = string.Empty; + } + } + UiSharedService.AttachToolTip("Hold CTRL to delete all game paths from the displayed list" + + UiSharedService.TooltipSeparator + "You usually do not need to do this. All animation and VFX data will be automatically handled through Lightless."); + ImGuiHelpers.ScaledDummy(5); + ImGuiHelpers.ScaledDummy(30); + ImGui.SameLine(); + ImGui.SetNextItemWidth((restContent - 30) / 2f); + ImGui.InputTextWithHint("##filterGamePath", "Filter by Game Path", ref _filterGamePath, 255); + ImGui.SameLine(); + ImGui.SetNextItemWidth((restContent - 30) / 2f); + ImGui.InputTextWithHint("##filterFilePath", "Filter by File Path", ref _filterFilePath, 255); + + using (var dataTable = ImRaii.Table("##table", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY | ImGuiTableFlags.RowBg)) + { + if (dataTable) + { + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); + ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); + ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + int id = 0; + foreach (var entry in selectedList) + { + if (!string.IsNullOrWhiteSpace(_filterGamePath) && !entry.Contains(_filterGamePath, StringComparison.OrdinalIgnoreCase)) + continue; + bool hasFileResolve = _filePathResolve.TryGetValue(entry, out var filePath); + + if (hasFileResolve && !string.IsNullOrEmpty(_filterFilePath) && !filePath!.Contains(_filterFilePath, StringComparison.OrdinalIgnoreCase)) + continue; + + using var imguiid = ImRaii.PushId(id++); + ImGui.TableNextColumn(); + bool isSelected = _storedPathsToRemove.Contains(entry, StringComparer.Ordinal); + if (ImGui.Checkbox("##", ref isSelected)) + { + if (isSelected) + _storedPathsToRemove.Add(entry); + else + _storedPathsToRemove.Remove(entry); + } + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry); + UiSharedService.AttachToolTip(entry + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + ImGui.SetClipboardText(entry); + } + ImGui.TableNextColumn(); + if (hasFileResolve) + { + ImGui.TextUnformatted(filePath ?? "Unk"); + UiSharedService.AttachToolTip(filePath ?? "Unk" + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) + { + ImGui.SetClipboardText(filePath); + } + } + else + { + ImGui.TextUnformatted("-"); + UiSharedService.AttachToolTip("Resolve Game Paths to used File Paths to display the associated file paths."); + } + } + } + } } } } - ImGui.SameLine(); - using (ImRaii.Group()) + + void ResetSelectionFilters() { - var selectedList = string.Equals(_selectedJobEntry, "alljobs", StringComparison.Ordinal) - ? config[_selectedStoredCharacter].GlobalPersistentCache - : (string.IsNullOrEmpty(_selectedJobEntry) ? [] : config[_selectedStoredCharacter].JobSpecificCache[uint.Parse(_selectedJobEntry)]); - ImGui.TextUnformatted($"Attached Files (Total Files: {selectedList.Count})"); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(3); - using (ImRaii.Disabled(string.IsNullOrEmpty(_selectedJobEntry))) - { - - var restContent = availableContentRegion.X - ImGui.GetCursorPosX(); - using var group = ImRaii.Group(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowRight, "Resolve Game Paths to used File Paths")) - { - _ = Task.Run(async () => - { - var paths = selectedList.ToArray(); - var resolved = await _ipcManager.Penumbra.ResolvePathsAsync(paths, []).ConfigureAwait(false); - _filePathResolve.Clear(); - - for (int i = 0; i < resolved.forward.Length; i++) - { - _filePathResolve[paths[i]] = resolved.forward[i]; - } - }); - } - ImGui.SameLine(); - ImGuiHelpers.ScaledDummy(20, 1); - ImGui.SameLine(); - using (ImRaii.Disabled(!_storedPathsToRemove.Any())) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Remove selected Game Paths")) - { - foreach (var item in _storedPathsToRemove) - { - selectedList.Remove(item); - } - - _transientConfigService.Save(); - _transientResourceManager.RebuildSemiTransientResources(); - _filterFilePath = string.Empty; - _filterGamePath = string.Empty; - } - } - ImGui.SameLine(); - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear ALL Game Paths")) - { - selectedList.Clear(); - _transientConfigService.Save(); - _transientResourceManager.RebuildSemiTransientResources(); - _filterFilePath = string.Empty; - _filterGamePath = string.Empty; - } - } - UiSharedService.AttachToolTip("Hold CTRL to delete all game paths from the displayed list" - + UiSharedService.TooltipSeparator + "You usually do not need to do this. All animation and VFX data will be automatically handled through Lightless."); - ImGuiHelpers.ScaledDummy(5); - ImGuiHelpers.ScaledDummy(30); - ImGui.SameLine(); - ImGui.SetNextItemWidth((restContent - 30) / 2f); - ImGui.InputTextWithHint("##filterGamePath", "Filter by Game Path", ref _filterGamePath, 255); - ImGui.SameLine(); - ImGui.SetNextItemWidth((restContent - 30) / 2f); - ImGui.InputTextWithHint("##filterFilePath", "Filter by File Path", ref _filterFilePath, 255); - - using (var dataTable = ImRaii.Table("##table", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.ScrollY | ImGuiTableFlags.RowBg)) - { - if (dataTable) - { - ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); - ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); - ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (restContent - 30) / 2f); - ImGui.TableSetupScrollFreeze(0, 1); - ImGui.TableHeadersRow(); - int id = 0; - foreach (var entry in selectedList) - { - if (!string.IsNullOrWhiteSpace(_filterGamePath) && !entry.Contains(_filterGamePath, StringComparison.OrdinalIgnoreCase)) - continue; - bool hasFileResolve = _filePathResolve.TryGetValue(entry, out var filePath); - - if (hasFileResolve && !string.IsNullOrEmpty(_filterFilePath) && !filePath!.Contains(_filterFilePath, StringComparison.OrdinalIgnoreCase)) - continue; - - using var imguiid = ImRaii.PushId(id++); - ImGui.TableNextColumn(); - bool isSelected = _storedPathsToRemove.Contains(entry, StringComparer.Ordinal); - if (ImGui.Checkbox("##", ref isSelected)) - { - if (isSelected) - _storedPathsToRemove.Add(entry); - else - _storedPathsToRemove.Remove(entry); - } - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry); - UiSharedService.AttachToolTip(entry + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); - if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) - { - ImGui.SetClipboardText(entry); - } - ImGui.TableNextColumn(); - if (hasFileResolve) - { - ImGui.TextUnformatted(filePath ?? "Unk"); - UiSharedService.AttachToolTip(filePath ?? "Unk" + UiSharedService.TooltipSeparator + "Click to copy to clipboard"); - if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) - { - ImGui.SetClipboardText(filePath); - } - } - else - { - ImGui.TextUnformatted("-"); - UiSharedService.AttachToolTip("Resolve Game Paths to used File Paths to display the associated file paths."); - } - } - } - } - } + _storedPathsToRemove.Clear(); + _filePathResolve.Clear(); + _filterFilePath = string.Empty; + _filterGamePath = string.Empty; } } + private void DrawRecording() + { + DrawRecordingHelpSection(); + DrawRecordingControlButtons(); + + if (_transientResourceManager.IsTransientRecording) + { + DrawRecordingActiveWarning(); + } + + ImGuiHelpers.ScaledDummy(5); + DrawRecordingReviewControls(); + ImGuiHelpers.ScaledDummy(5); + DrawRecordedTransientsTable(); + } + + private static void DrawRecordingHelpSection() { UiSharedService.DrawTree("What is this? (Explanation / Help)", () => { @@ -377,6 +548,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGuiColors.DalamudRed, 800); ImGuiHelpers.ScaledDummy(5); }); + } + + private void DrawRecordingControlButtons() + { using (ImRaii.Disabled(_transientResourceManager.IsTransientRecording)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Play, "Start Transient Recording")) @@ -396,15 +571,18 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _transientRecordCts.Cancel(); } } - if (_transientResourceManager.IsTransientRecording) - { - ImGui.SameLine(); - UiSharedService.ColorText($"RECORDING - Time Remaining: {_transientResourceManager.RecordTimeRemaining.Value}", UIColors.Get("LightlessYellow")); - ImGuiHelpers.ScaledDummy(5); - UiSharedService.DrawGroupedCenteredColorText("DO NOT CHANGE YOUR APPEARANCE OR MODS WHILE RECORDING, YOU CAN ACCIDENTALLY MAKE SOME OF YOUR APPEARANCE RELATED MODS PERMANENT.", ImGuiColors.DalamudRed, 800); - } + } + private void DrawRecordingActiveWarning() + { + ImGui.SameLine(); + UiSharedService.ColorText($"RECORDING - Time Remaining: {_transientResourceManager.RecordTimeRemaining.Value}", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(5); + UiSharedService.DrawGroupedCenteredColorText("DO NOT CHANGE YOUR APPEARANCE OR MODS WHILE RECORDING, YOU CAN ACCIDENTALLY MAKE SOME OF YOUR APPEARANCE RELATED MODS PERMANENT.", ImGuiColors.DalamudRed, 800); + } + + private void DrawRecordingReviewControls() + { ImGui.Checkbox("Show previously added transient files in the recording", ref _showAlreadyAddedTransients); _uiSharedService.DrawHelpText("Use this only if you want to see what was previously already caught by Lightless"); ImGuiHelpers.ScaledDummy(5); @@ -425,49 +603,53 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase UiSharedService.DrawGroupedCenteredColorText("Please review the recorded mod files before saving and deselect files that got into the recording on accident.", UIColors.Get("LightlessYellow")); ImGuiHelpers.ScaledDummy(5); } + } - ImGuiHelpers.ScaledDummy(5); + private void DrawRecordedTransientsTable() + { var width = ImGui.GetContentRegionAvail(); using var table = ImRaii.Table("Recorded Transients", 4, ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg); - if (table) + if (!table) { - int id = 0; - ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); - ImGui.TableSetupColumn("Owner", ImGuiTableColumnFlags.WidthFixed, 100); - ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); - ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); - ImGui.TableSetupScrollFreeze(0, 1); - ImGui.TableHeadersRow(); - var transients = _transientResourceManager.RecordedTransients.ToList(); - transients.Reverse(); - foreach (var value in transients) - { - if (value.AlreadyTransient && !_showAlreadyAddedTransients) - continue; + return; + } - using var imguiid = ImRaii.PushId(id++); - if (value.AlreadyTransient) - { - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); - } - ImGui.TableNextColumn(); - bool addTransient = value.AddTransient; - if (ImGui.Checkbox("##add", ref addTransient)) - { - value.AddTransient = addTransient; - } - ImGui.TableNextColumn(); - ImGui.TextUnformatted(value.Owner.Name); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(value.GamePath); - UiSharedService.AttachToolTip(value.GamePath); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(value.FilePath); - UiSharedService.AttachToolTip(value.FilePath); - if (value.AlreadyTransient) - { - ImGui.PopStyleColor(); - } + int id = 0; + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 30); + ImGui.TableSetupColumn("Owner", ImGuiTableColumnFlags.WidthFixed, 100); + ImGui.TableSetupColumn("Game Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); + ImGui.TableSetupColumn("File Path", ImGuiTableColumnFlags.WidthFixed, (width.X - 30 - 100) / 2f); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + var transients = _transientResourceManager.RecordedTransients.ToList(); + transients.Reverse(); + foreach (var value in transients) + { + if (value.AlreadyTransient && !_showAlreadyAddedTransients) + continue; + + using var imguiid = ImRaii.PushId(id++); + if (value.AlreadyTransient) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + } + ImGui.TableNextColumn(); + bool addTransient = value.AddTransient; + if (ImGui.Checkbox("##add", ref addTransient)) + { + value.AddTransient = addTransient; + } + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value.Owner.Name); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value.GamePath); + UiSharedService.AttachToolTip(value.GamePath); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(value.FilePath); + UiSharedService.AttachToolTip(value.FilePath); + if (value.AlreadyTransient) + { + ImGui.PopStyleColor(); } } } @@ -481,6 +663,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase if (_cachedAnalysis!.Count == 0) return; + EnsureTextureRows(); + bool isAnalyzing = _characterAnalyzer.IsAnalysisRunning; if (isAnalyzing) { @@ -513,31 +697,19 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.Separator(); - ImGui.TextUnformatted("Total files:"); - ImGui.SameLine(); - ImGui.TextUnformatted(_cachedAnalysis!.Values.Sum(c => c.Values.Count).ToString()); - ImGui.SameLine(); - using (var font = ImRaii.PushFont(UiBuilder.IconFont)) - { - ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString()); - } - if (ImGui.IsItemHovered()) - { - string text = ""; - var groupedfiles = _cachedAnalysis.Values.SelectMany(f => f.Values).GroupBy(f => f.FileType, StringComparer.Ordinal); - text = string.Join(Environment.NewLine, groupedfiles.OrderBy(f => f.Key, StringComparer.Ordinal) - .Select(f => f.Key + ": " + f.Count() + " files, size: " + UiSharedService.ByteToString(f.Sum(v => v.OriginalSize)) - + ", compressed: " + UiSharedService.ByteToString(f.Sum(v => v.CompressedSize)))); - ImGui.SetTooltip(text); - } - ImGui.TextUnformatted("Total size (actual):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(_cachedAnalysis!.Sum(c => c.Value.Sum(c => c.Value.OriginalSize)))); - ImGui.TextUnformatted("Total size (compressed for up/download only):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(_cachedAnalysis!.Sum(c => c.Value.Sum(c => c.Value.CompressedSize)))); - ImGui.TextUnformatted($"Total modded model triangles: {_cachedAnalysis.Sum(c => c.Value.Sum(f => f.Value.Triangles))}"); - ImGui.Separator(); + var totalFileCount = _cachedAnalysis!.Values.Sum(c => c.Values.Count); + var totalActualSize = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.OriginalSize)); + var totalCompressedSize = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.CompressedSize)); + var totalTriangles = _cachedAnalysis.Sum(c => c.Value.Sum(entry => entry.Value.Triangles)); + var breakdown = string.Join(Environment.NewLine, + _cachedAnalysis.Values + .SelectMany(f => f.Values) + .GroupBy(f => f.FileType, StringComparer.Ordinal) + .OrderBy(f => f.Key, StringComparer.Ordinal) + .Select(f => $"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}")); + + DrawAnalysisOverview(totalFileCount, totalActualSize, totalCompressedSize, totalTriangles, breakdown); + using var tabbar = ImRaii.TabBar("objectSelection"); foreach (var kvp in _cachedAnalysis) { @@ -549,221 +721,31 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { var groupedfiles = kvp.Value.Select(v => v.Value).GroupBy(f => f.FileType, StringComparer.Ordinal).OrderBy(k => k.Key, StringComparer.Ordinal).ToList(); - ImGui.PushStyleVar(ImGuiStyleVar.CellPadding, new Vector2(1f, 1f)); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1f, 1f)); - - if (ImGui.BeginTable($"##fileStats_{kvp.Key}", 3, - ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit)) - { - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"Files for {kvp.Key}"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(kvp.Value.Count.ToString()); - ImGui.SameLine(); - using (var font = ImRaii.PushFont(UiBuilder.IconFont)) - ImGui.TextUnformatted(FontAwesomeIcon.InfoCircle.ToIconString()); - if (ImGui.IsItemHovered()) - { - string text = string.Join(Environment.NewLine, groupedfiles.Select(f => - $"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}")); - ImGui.SetTooltip(text); - } - ImGui.TableNextColumn(); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{kvp.Key} size (actual):"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.OriginalSize))); - ImGui.TableNextColumn(); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{kvp.Key} size (compressed for up/download only):"); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize))); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - ImGui.TableNextColumn(); - - var vramUsage = groupedfiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); - if (vramUsage != null) - { - var actualVramUsage = vramUsage.Sum(f => f.OriginalSize); - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{kvp.Key} VRAM usage:"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(actualVramUsage)); - ImGui.TableNextColumn(); - - if (_playerPerformanceConfig.Current.WarnOnExceedingThresholds - || _playerPerformanceConfig.Current.ShowPerformanceIndicator) - { - var currentVramWarning = _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB; - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted("Configured VRAM threshold:"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{currentVramWarning} MiB."); - ImGui.TableNextColumn(); - if (currentVramWarning * 1024 * 1024 < actualVramUsage) - { - UiSharedService.ColorText( - $"You exceed your own threshold by {UiSharedService.ByteToString(actualVramUsage - (currentVramWarning * 1024 * 1024))}", - UIColors.Get("LightlessYellow")); - } - } - } - - var actualTriCount = kvp.Value.Sum(f => f.Value.Triangles); - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{kvp.Key} modded model triangles:"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(actualTriCount.ToString()); - ImGui.TableNextColumn(); - - if (_playerPerformanceConfig.Current.WarnOnExceedingThresholds - || _playerPerformanceConfig.Current.ShowPerformanceIndicator) - { - var currentTriWarning = _playerPerformanceConfig.Current.TrisWarningThresholdThousands; - - ImGui.TableNextRow(); - ImGui.TableNextColumn(); - ImGui.TextUnformatted("Configured triangle threshold:"); - ImGui.TableNextColumn(); - ImGui.TextUnformatted($"{currentTriWarning * 1000} triangles."); - ImGui.TableNextColumn(); - if (currentTriWarning * 1000 < actualTriCount) - { - UiSharedService.ColorText( - $"You exceed your own threshold by {actualTriCount - (currentTriWarning * 1000)}", - UIColors.Get("LightlessYellow")); - } - } - - ImGui.EndTable(); - } - - ImGui.PopStyleVar(2); - - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); - - _uiSharedService.MediumText("Selected file:", UIColors.Get("LightlessBlue")); - ImGui.SameLine(); - _uiSharedService.MediumText(_selectedHash, UIColors.Get("LightlessYellow")); - - if (_cachedAnalysis[_selectedObjectTab].TryGetValue(_selectedHash, out CharacterAnalyzer.FileDataEntry? item)) - { - var filePaths = item.FilePaths; - UiSharedService.ColorText("Local file path:", UIColors.Get("LightlessBlue")); - ImGui.SameLine(); - UiSharedService.TextWrapped(filePaths[0]); - if (filePaths.Count > 1) - { - ImGui.SameLine(); - ImGui.TextUnformatted($"(and {filePaths.Count - 1} more)"); - ImGui.SameLine(); - _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); - UiSharedService.AttachToolTip(string.Join(Environment.NewLine, filePaths.Skip(1))); - } - - var gamepaths = item.GamePaths; - UiSharedService.ColorText("Used by game path:", UIColors.Get("LightlessBlue")); - ImGui.SameLine(); - UiSharedService.TextWrapped(gamepaths[0]); - if (gamepaths.Count > 1) - { - ImGui.SameLine(); - ImGui.TextUnformatted($"(and {gamepaths.Count - 1} more)"); - ImGui.SameLine(); - _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); - UiSharedService.AttachToolTip(string.Join(Environment.NewLine, gamepaths.Skip(1))); - } - } - - ImGui.Separator(); + DrawObjectOverview(kvp.Key, kvp.Value, groupedfiles); if (_selectedObjectTab != kvp.Key) { _selectedHash = string.Empty; _selectedObjectTab = kvp.Key; _selectedFileTypeTab = string.Empty; - _enableBc7ConversionMode = false; - _texturesToConvert.Clear(); } - using var fileTabBar = ImRaii.TabBar("fileTabs"); + var otherFileGroups = groupedfiles + .Where(g => !string.Equals(g.Key, "tex", StringComparison.Ordinal)) + .ToList(); - foreach (IGrouping? fileGroup in groupedfiles) + if (!string.IsNullOrEmpty(_selectedFileTypeTab) && + otherFileGroups.TrueForAll(g => !string.Equals(g.Key, _selectedFileTypeTab, StringComparison.Ordinal))) { - string fileGroupText = fileGroup.Key + " [" + fileGroup.Count() + "]"; - var requiresCompute = fileGroup.Any(k => !k.IsComputed); - using var tabcol = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.Color(UIColors.Get("LightlessYellow")), requiresCompute); - if (requiresCompute) - { - fileGroupText += " (!)"; - } - ImRaii.IEndObject fileTab; - using (var textcol = ImRaii.PushColor(ImGuiCol.Text, UiSharedService.Color(new(0, 0, 0, 1)), - requiresCompute && !string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal))) - { - fileTab = ImRaii.TabItem(fileGroupText + "###" + fileGroup.Key); - } - - if (!fileTab) { fileTab.Dispose(); continue; } - - if (!string.Equals(fileGroup.Key, _selectedFileTypeTab, StringComparison.Ordinal)) - { - _selectedFileTypeTab = fileGroup.Key; - _selectedHash = string.Empty; - _enableBc7ConversionMode = false; - _texturesToConvert.Clear(); - } - - ImGui.TextUnformatted($"{fileGroup.Key} files"); - ImGui.SameLine(); - ImGui.TextUnformatted(fileGroup.Count().ToString()); - - ImGui.TextUnformatted($"{fileGroup.Key} files size (actual):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.OriginalSize))); - - ImGui.TextUnformatted($"{fileGroup.Key} files size (compressed for up/download only):"); - ImGui.SameLine(); - ImGui.TextUnformatted(UiSharedService.ByteToString(fileGroup.Sum(c => c.CompressedSize))); - - if (string.Equals(_selectedFileTypeTab, "tex", StringComparison.Ordinal)) - { - ImGui.Checkbox("Enable BC7 Conversion Mode", ref _enableBc7ConversionMode); - if (_enableBc7ConversionMode) - { - UiSharedService.ColorText("WARNING BC7 CONVERSION:", UIColors.Get("LightlessYellow")); - ImGui.SameLine(); - UiSharedService.ColorText("Converting textures to BC7 is irreversible!", ImGuiColors.DalamudRed); - UiSharedService.ColorTextWrapped("- Converting textures to BC7 will reduce their size (compressed and uncompressed) drastically. It is recommended to be used for large (4k+) textures." + - Environment.NewLine + "- Some textures, especially ones utilizing colorsets, might not be suited for BC7 conversion and might produce visual artifacts." + - Environment.NewLine + "- Before converting textures, make sure to have the original files of the mod you are converting so you can reimport it in case of issues." + - Environment.NewLine + "- Conversion will convert all found texture duplicates (entries with more than 1 file path) automatically." + - Environment.NewLine + "- Converting textures to BC7 is a very expensive operation and, depending on the amount of textures to convert, will take a while to complete." - , UIColors.Get("LightlessYellow")); - if (_texturesToConvert.Count > 0 && _uiSharedService.IconTextButton(FontAwesomeIcon.PlayCircle, "Start conversion of " + _texturesToConvert.Count + " texture(s)")) - { - _conversionCancellationTokenSource = _conversionCancellationTokenSource.CancelRecreate(); - _conversionTask = _ipcManager.Penumbra.ConvertTextureFiles(_logger, _texturesToConvert, _conversionProgress, _conversionCancellationTokenSource.Token); - } - } - } - - ImGui.Separator(); - DrawTable(fileGroup); - - fileTab.Dispose(); + _selectedFileTypeTab = string.Empty; } + + if (string.IsNullOrEmpty(_selectedFileTypeTab) && otherFileGroups.Count > 0) + { + _selectedFileTypeTab = otherFileGroups[0].Key; + } + + DrawTextureWorkspace(kvp.Key, otherFileGroups); } } } @@ -772,146 +754,1883 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { _hasUpdate = true; _selectedHash = string.Empty; - _enableBc7ConversionMode = false; - _texturesToConvert.Clear(); + _selectedTextureKey = string.Empty; + _selectedTextureKeys.Clear(); + _textureSelections.Clear(); + ResetTextureFilters(); + _textureRowsDirty = true; + _conversionFailed = false; } protected override void Dispose(bool disposing) { base.Dispose(disposing); + foreach (var preview in _texturePreviews.Values) + { + preview.Texture?.Dispose(); + } + _texturePreviews.Clear(); _conversionProgress.ProgressChanged -= ConversionProgress_ProgressChanged; } - private void ConversionProgress_ProgressChanged(object? sender, (string, int) e) + private void ConversionProgress_ProgressChanged(object? sender, TextureConversionProgress e) { - _conversionCurrentFileName = e.Item1; - _conversionCurrentFileProgress = e.Item2; + _lastConversionProgress = e; + _conversionTotalJobs = e.Total; + _conversionCurrentFileName = Path.GetFileName(e.CurrentJob.OutputFile); + _conversionCurrentFileProgress = Math.Min(e.Completed + 1, e.Total); + } + + private void EnsureTextureRows() + { + if (!_textureRowsDirty || _cachedAnalysis == null) + { + return; + } + + _textureRows.Clear(); + HashSet validKeys = new(StringComparer.OrdinalIgnoreCase); + + foreach (var (objectKind, entries) in _cachedAnalysis) + { + foreach (var entry in entries.Values) + { + if (!string.Equals(entry.FileType, "tex", StringComparison.Ordinal)) + { + continue; + } + + if (entry.FilePaths.Count == 0) + { + continue; + } + + var primaryFile = entry.FilePaths[0]; + var duplicatePaths = entry.FilePaths.Skip(1).ToList(); + var primaryGamePath = entry.GamePaths.FirstOrDefault() ?? string.Empty; + var classificationPath = string.IsNullOrEmpty(primaryGamePath) ? primaryFile : primaryGamePath; + var mapKind = _textureMetadataHelper.DetermineMapKind(primaryGamePath, primaryFile); + var category = _textureMetadataHelper.DetermineCategory(classificationPath); + var slot = _textureMetadataHelper.DetermineSlot(category, classificationPath); + var format = entry.Format.Value; + var suggestion = _textureMetadataHelper.GetSuggestedTarget(format, mapKind); + TextureCompressionTarget? currentTarget = _textureMetadataHelper.TryMapFormatToTarget(format, out var mappedTarget) + ? mappedTarget + : null; + var displayName = Path.GetFileName(primaryFile); + + var row = new TextureRow( + objectKind, + entry, + primaryFile, + duplicatePaths, + entry.GamePaths.ToList(), + primaryGamePath, + format, + mapKind, + category, + slot, + displayName, + currentTarget, + suggestion?.Target, + suggestion?.Reason); + + validKeys.Add(row.Key); + _textureRows.Add(row); + + if (row.IsAlreadyCompressed) + { + _selectedTextureKeys.Remove(row.Key); + _textureSelections.Remove(row.Key); + } + } + } + + _textureRows.Sort((a, b) => + { + var comp = a.ObjectKind.CompareTo(b.ObjectKind); + if (comp != 0) + return comp; + + comp = string.Compare(a.Slot, b.Slot, StringComparison.OrdinalIgnoreCase); + if (comp != 0) + return comp; + + return string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); + }); + + _selectedTextureKeys.RemoveWhere(key => !validKeys.Contains(key)); + + foreach (var key in _texturePreviews.Keys.ToArray()) + { + if (!validKeys.Contains(key) && _texturePreviews.TryGetValue(key, out var preview)) + { + preview.Texture?.Dispose(); + _texturePreviews.Remove(key); + } + } + + foreach (var key in _textureSelections.Keys.ToArray()) + { + if (!validKeys.Contains(key)) + { + _textureSelections.Remove(key); + continue; + } + + _textureSelections[key] = _textureCompressionService.NormalizeTarget(_textureSelections[key]); + } + + if (!string.IsNullOrEmpty(_selectedTextureKey) && !validKeys.Contains(_selectedTextureKey)) + { + _selectedTextureKey = string.Empty; + } + + _textureRowsDirty = false; + } + + private static string MakeTextureKey(ObjectKind objectKind, string primaryFilePath) => + $"{objectKind}|{primaryFilePath}".ToLowerInvariant(); + + private void ResetTextureFilters() + { + _textureCategoryFilter = null; + _textureSlotFilter = "All"; + _textureMapFilter = null; + _textureTargetFilter = null; + _textureSearch = string.Empty; + } + + private void DrawAnalysisOverview(int totalFiles, long totalActualSize, long totalCompressedSize, long totalTriangles, string breakdownTooltip) + { + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessGreen"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.4f); + var infoColor = ImGuiColors.DalamudGrey; + var diff = totalActualSize - totalCompressedSize; + string? diffText = null; + Vector4? diffColor = null; + if (diff > 0) + { + diffText = $"Saved {UiSharedService.ByteToString(diff)}"; + diffColor = UIColors.Get("LightlessGreen"); + } + else if (diff < 0) + { + diffText = $"Over by {UiSharedService.ByteToString(Math.Abs(diff))}"; + diffColor = UIColors.Get("DimRed"); + } + + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 44f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("analysisOverview", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) + { + if (ImGui.BeginTable("analysisOverviewTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + DrawSummaryCell(FontAwesomeIcon.ListUl, accent, $"{totalFiles:N0}", totalFiles == 1 ? "Tracked file" : "Tracked files", infoColor, scale, tooltip: breakdownTooltip); + DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(totalActualSize), "Actual size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(totalCompressedSize), "Compressed size", infoColor, scale, diffText, diffColor); + DrawSummaryCell(FontAwesomeIcon.ChartLine, UIColors.Get("LightlessPurple"), totalTriangles.ToString("N0", CultureInfo.InvariantCulture), "Modded triangles", infoColor, scale); + ImGui.EndTable(); + } + } + } + } + + ImGuiHelpers.ScaledDummy(6); + } + + private void DrawObjectOverview( + ObjectKind objectKind, + IReadOnlyDictionary entries, + IReadOnlyList> groupedFiles) + { + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.32f); + var infoColor = ImGuiColors.DalamudGrey; + var fileCount = entries.Count; + var actualSize = entries.Sum(c => c.Value.OriginalSize); + var compressedSize = entries.Sum(c => c.Value.CompressedSize); + var triangles = entries.Sum(c => c.Value.Triangles); + var breakdown = string.Join(Environment.NewLine, + groupedFiles.Select(f => + $"{f.Key}: {f.Count()} files, size: {UiSharedService.ByteToString(f.Sum(v => v.OriginalSize))}, compressed: {UiSharedService.ByteToString(f.Sum(v => v.CompressedSize))}")); + + var savings = actualSize - compressedSize; + string? compressedExtra = null; + Vector4? compressedExtraColor = null; + if (savings > 0) + { + compressedExtra = $"Saved {UiSharedService.ByteToString(savings)}"; + compressedExtraColor = UIColors.Get("LightlessGreen"); + } + else if (savings < 0) + { + compressedExtra = $"Over by {UiSharedService.ByteToString(Math.Abs(savings))}"; + compressedExtraColor = UIColors.Get("DimRed"); + } + + long actualVram = 0; + var vramGroup = groupedFiles.SingleOrDefault(v => string.Equals(v.Key, "tex", StringComparison.Ordinal)); + if (vramGroup != null) + { + actualVram = vramGroup.Sum(f => f.OriginalSize); + } + + string? vramExtra = null; + Vector4? vramExtraColor = null; + var vramSub = vramGroup != null ? "VRAM usage" : "VRAM usage (no textures)"; + var showThresholds = _playerPerformanceConfig.Current.WarnOnExceedingThresholds + || _playerPerformanceConfig.Current.ShowPerformanceIndicator; + if (showThresholds && actualVram > 0) + { + var thresholdBytes = Math.Max(0, _playerPerformanceConfig.Current.VRAMSizeWarningThresholdMiB) * 1024L * 1024L; + if (thresholdBytes > 0) + { + if (actualVram > thresholdBytes) + { + vramExtra = $"Over by {UiSharedService.ByteToString(actualVram - thresholdBytes)}"; + vramExtraColor = UIColors.Get("LightlessYellow"); + } + else + { + vramExtra = $"Remaining {UiSharedService.ByteToString(thresholdBytes - actualVram)}"; + vramExtraColor = UIColors.Get("LightlessGreen"); + } + } + } + + string? triExtra = null; + Vector4? triExtraColor = null; + if (showThresholds) + { + var triThreshold = Math.Max(0, _playerPerformanceConfig.Current.TrisWarningThresholdThousands) * 1000; + if (triThreshold > 0) + { + if (triangles > triThreshold) + { + triExtra = $"Over by {(triangles - triThreshold).ToString("N0", CultureInfo.InvariantCulture)}"; + triExtraColor = UIColors.Get("LightlessYellow"); + } + else + { + triExtra = $"Remaining {(triThreshold - triangles).ToString("N0", CultureInfo.InvariantCulture)}"; + triExtraColor = UIColors.Get("LightlessGreen"); + } + } + } + + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.4f, 46f * scale); + var availableWidth = ImGui.GetContentRegionAvail().X; + var summaryWidth = objectKind == ObjectKind.Player + ? availableWidth + : MathF.Min(availableWidth, 760f * scale); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child($"objectOverview##{objectKind}", new Vector2(summaryWidth, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) + { + if (ImGui.BeginTable($"objectOverviewTable##{objectKind}", 5, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + DrawSummaryCell(FontAwesomeIcon.ClipboardList, accent, $"{fileCount:N0}", $"{objectKind} files", infoColor, scale, tooltip: breakdown); + DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(actualSize), "Actual size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(compressedSize), "Compressed size", infoColor, scale, compressedExtra, compressedExtraColor); + DrawSummaryCell(FontAwesomeIcon.Memory, UIColors.Get("LightlessBlue"), UiSharedService.ByteToString(actualVram), vramSub, infoColor, scale, vramExtra, vramExtraColor); + DrawSummaryCell(FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessPurple"), triangles.ToString("N0", CultureInfo.InvariantCulture), "Modded triangles", infoColor, scale, triExtra, triExtraColor); + ImGui.EndTable(); + } + } + } + } + + ImGuiHelpers.ScaledDummy(4); + } + + private enum TextureWorkspaceTab + { + Textures, + OtherFiles + } + + private sealed record TextureRow( + ObjectKind ObjectKind, + CharacterAnalyzer.FileDataEntry Entry, + string PrimaryFilePath, + IReadOnlyList DuplicateFilePaths, + IReadOnlyList GamePaths, + string PrimaryGamePath, + string Format, + TextureMapKind MapKind, + TextureUsageCategory Category, + string Slot, + string DisplayName, + TextureCompressionTarget? CurrentTarget, + TextureCompressionTarget? SuggestedTarget, + string? SuggestionReason) + { + public string Key { get; } = MakeTextureKey(ObjectKind, PrimaryFilePath); + public string Hash => Entry.Hash; + public long OriginalSize => Entry.OriginalSize; + public long CompressedSize => Entry.CompressedSize; + public bool IsComputed => Entry.IsComputed; + public bool IsAlreadyCompressed => CurrentTarget.HasValue; + } + + private sealed class TexturePreviewState + { + public Task? LoadTask { get; set; } + public IDalamudTextureWrap? Texture { get; set; } + public string? ErrorMessage { get; set; } + public DateTime LastAccessUtc { get; set; } = DateTime.UtcNow; + } + + private void DrawTextureWorkspace(ObjectKind objectKind, IReadOnlyList> otherFileGroups) + { + if (!_textureWorkspaceTabs.ContainsKey(objectKind)) + { + _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; + } + + if (otherFileGroups.Count == 0) + { + _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; + DrawTextureTabContent(objectKind); + return; + } + + using var tabBar = ImRaii.TabBar($"textureWorkspaceTabs##{objectKind}"); + + using (var texturesTab = ImRaii.TabItem($"Textures###textures_{objectKind}")) + { + if (texturesTab) + { + if (_textureWorkspaceTabs[objectKind] != TextureWorkspaceTab.Textures) + { + _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.Textures; + } + DrawTextureTabContent(objectKind); + } + } + + using (var otherFilesTab = ImRaii.TabItem($"Other file types###other_{objectKind}")) + { + if (otherFilesTab) + { + if (_textureWorkspaceTabs[objectKind] != TextureWorkspaceTab.OtherFiles) + { + _textureWorkspaceTabs[objectKind] = TextureWorkspaceTab.OtherFiles; + } + DrawOtherFileWorkspace(otherFileGroups); + } + } + } + + private void DrawTextureTabContent(ObjectKind objectKind) + { + var scale = ImGuiHelpers.GlobalScale; + var objectRows = _textureRows.Where(row => row.ObjectKind == objectKind).ToList(); + var hasAnyTextureRows = objectRows.Count > 0; + var availableCategories = objectRows.Select(row => row.Category) + .Distinct() + .OrderBy(c => c.ToString(), StringComparer.Ordinal) + .ToList(); + var availableSlots = objectRows + .Select(row => row.Slot) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase) + .ToList(); + var availableMapKinds = objectRows.Select(row => row.MapKind) + .Distinct() + .OrderBy(m => m.ToString(), StringComparer.Ordinal) + .ToList(); + + var totalTextureCount = objectRows.Count; + var totalTextureOriginal = objectRows.Sum(row => row.OriginalSize); + var totalTextureCompressed = objectRows.Sum(row => row.CompressedSize); + + IEnumerable filtered = objectRows; + + if (_textureCategoryFilter is { } categoryFilter) + { + filtered = filtered.Where(row => row.Category == categoryFilter); + } + + if (!string.Equals(_textureSlotFilter, "All", StringComparison.Ordinal)) + { + filtered = filtered.Where(row => string.Equals(row.Slot, _textureSlotFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (_textureMapFilter is { } mapFilter) + { + filtered = filtered.Where(row => row.MapKind == mapFilter); + } + + if (_textureTargetFilter is { } targetFilter) + { + filtered = filtered.Where(row => + (row.CurrentTarget != null && row.CurrentTarget == targetFilter) || + (row.CurrentTarget == null && row.SuggestedTarget == targetFilter)); + } + + if (!string.IsNullOrWhiteSpace(_textureSearch)) + { + var term = _textureSearch.Trim(); + filtered = filtered.Where(row => + row.DisplayName.Contains(term, StringComparison.OrdinalIgnoreCase) || + row.PrimaryGamePath.Contains(term, StringComparison.OrdinalIgnoreCase) || + row.Hash.Contains(term, StringComparison.OrdinalIgnoreCase)); + } + + var rows = filtered.ToList(); + + if (!string.IsNullOrEmpty(_selectedTextureKey) && rows.All(r => r.Key != _selectedTextureKey)) + { + _selectedTextureKey = rows.FirstOrDefault()?.Key ?? string.Empty; + } + + var totalOriginal = rows.Sum(r => r.OriginalSize); + var totalCompressed = rows.Sum(r => r.CompressedSize); + + var availableSize = ImGui.GetContentRegionAvail(); + var windowPos = ImGui.GetWindowPos(); + var spacingX = ImGui.GetStyle().ItemSpacing.X; + var splitterWidth = 6f * scale; + const float minFilterWidth = MinTextureFilterPaneWidth; + const float minDetailWidth = MinTextureDetailPaneWidth; + const float minCenterWidth = 340f; + + var dynamicFilterMax = Math.Max(minFilterWidth, availableSize.X - minDetailWidth - minCenterWidth - 2 * (splitterWidth + spacingX)); + var filterMaxBound = Math.Min(MaxTextureFilterPaneWidth, dynamicFilterMax); + var filterWidth = Math.Clamp(_textureFilterPaneWidth, minFilterWidth, filterMaxBound); + + var dynamicDetailMax = Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)); + var detailMaxBound = Math.Min(MaxTextureDetailPaneWidth, dynamicDetailMax); + var detailWidth = Math.Clamp(_textureDetailPaneWidth, minDetailWidth, detailMaxBound); + + var centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); + + if (centerWidth < minCenterWidth) + { + var deficit = minCenterWidth - centerWidth; + detailWidth = Math.Clamp(detailWidth - deficit, minDetailWidth, + Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); + centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); + if (centerWidth < minCenterWidth) + { + deficit = minCenterWidth - centerWidth; + filterWidth = Math.Clamp(filterWidth - deficit, minFilterWidth, + Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - detailWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); + detailWidth = Math.Clamp(detailWidth, minDetailWidth, + Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); + centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); + if (centerWidth < minCenterWidth) + { + centerWidth = minCenterWidth; + } + } + } + + _textureFilterPaneWidth = filterWidth; + _textureDetailPaneWidth = detailWidth; + + ImGui.BeginGroup(); + using (var filters = ImRaii.Child("textureFilters", new Vector2(filterWidth, 0), true)) + { + if (filters) + { + DrawTextureFilters( + availableCategories, + availableSlots, + availableMapKinds, + totalTextureCount, + totalTextureOriginal, + totalTextureCompressed); + } + } + ImGui.EndGroup(); + + var filterMin = ImGui.GetItemRectMin(); + var filterMax = ImGui.GetItemRectMax(); + var filterHeight = filterMax.Y - filterMin.Y; + var filterTopLocal = filterMin - windowPos; + var maxFilterResize = Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - minDetailWidth - 2 * (splitterWidth + spacingX))); + DrawVerticalResizeHandle("##textureFilterSplitter", filterTopLocal.Y, filterHeight, ref _textureFilterPaneWidth, minFilterWidth, maxFilterResize); + + TextureRow? selectedRow; + ImGui.BeginGroup(); + using (var tableChild = ImRaii.Child("textureTableArea", new Vector2(centerWidth, 0), false)) + { + selectedRow = DrawTextureTable(rows, totalOriginal, totalCompressed, hasAnyTextureRows); + } + ImGui.EndGroup(); + + var tableMin = ImGui.GetItemRectMin(); + var tableMax = ImGui.GetItemRectMax(); + var tableHeight = tableMax.Y - tableMin.Y; + var tableTopLocal = tableMin - windowPos; + var maxDetailResize = Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - _textureFilterPaneWidth - minCenterWidth - 2 * (splitterWidth + spacingX))); + DrawVerticalResizeHandle("##textureDetailSplitter", tableTopLocal.Y, tableHeight, ref _textureDetailPaneWidth, minDetailWidth, maxDetailResize, invert: true); + + ImGui.BeginGroup(); + using (var detailChild = ImRaii.Child("textureDetailPane", new Vector2(detailWidth, 0), true)) + { + DrawTextureDetail(selectedRow); + } + ImGui.EndGroup(); + } + + private void DrawTextureFilters( + IReadOnlyList categories, + IReadOnlyList slots, + IReadOnlyList mapKinds, + int totalTextureCount, + long totalTextureOriginal, + long totalTextureCompressed) + { + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessBlue"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var infoColor = ImGuiColors.DalamudGrey; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + { + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + var summaryHeight = MathF.Max(lineHeight * 2.4f, ImGui.GetFrameHeightWithSpacing() * 2.2f); + using (var totals = ImRaii.Child("textureTotalsSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (totals) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) + { + if (ImGui.BeginTable("textureTotalsSummaryTable", 3, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + DrawSummaryCell(FontAwesomeIcon.Images, accent, $"{totalTextureCount:N0}", totalTextureCount == 1 ? "tex file" : "tex files", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.FileArchive, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(totalTextureOriginal), "Actual size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(totalTextureCompressed), "Compressed size", infoColor, scale); + ImGui.EndTable(); + } + } + } + } + } + + ImGuiHelpers.ScaledDummy(1); + + ImGui.TextUnformatted("Filters"); + ImGui.Separator(); + + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint("##textureSearch", "Search hash, path, or file name...", ref _textureSearch, 256); + + ImGuiHelpers.ScaledDummy(6); + + DrawEnumFilterCombo("Category", "All Categories", ref _textureCategoryFilter, categories); + DrawStringFilterCombo("Slot", ref _textureSlotFilter, slots, "All"); + DrawEnumFilterCombo("Map Type", "All Map Types", ref _textureMapFilter, mapKinds); + + if (_textureTargetFilter.HasValue && !_textureCompressionService.IsTargetSelectable(_textureTargetFilter.Value)) + { + _textureTargetFilter = null; + } + + DrawEnumFilterCombo("Compression", "Any Compression", ref _textureTargetFilter, _textureCompressionService.SelectableTargets); + + ImGuiHelpers.ScaledDummy(8); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Undo, "Reset filters")) + { + ResetTextureFilters(); + } + } + + private static void DrawEnumFilterCombo( + string label, + string allLabel, + ref T? currentSelection, + IEnumerable options) + where T : struct, Enum + { + var displayLabel = currentSelection?.ToString() ?? allLabel; + if (!ImGui.BeginCombo(label, displayLabel)) + { + return; + } + + bool noSelection = !currentSelection.HasValue; + if (ImGui.Selectable(allLabel, noSelection)) + { + currentSelection = null; + } + if (noSelection) + { + ImGui.SetItemDefaultFocus(); + } + + var comparer = EqualityComparer.Default; + foreach (var option in options) + { + bool selected = currentSelection.HasValue && comparer.Equals(currentSelection.Value, option); + if (ImGui.Selectable(option.ToString(), selected)) + { + currentSelection = option; + } + if (selected) + { + ImGui.SetItemDefaultFocus(); + } + } + + ImGui.EndCombo(); + } + + private static void DrawStringFilterCombo( + string label, + ref string currentSelection, + IEnumerable options, + string allLabel) + { + var displayLabel = string.IsNullOrEmpty(currentSelection) || string.Equals(currentSelection, allLabel, StringComparison.Ordinal) + ? allLabel + : currentSelection; + if (!ImGui.BeginCombo(label, displayLabel)) + { + return; + } + + bool allSelected = string.Equals(currentSelection, allLabel, StringComparison.Ordinal); + if (ImGui.Selectable(allLabel, allSelected)) + { + currentSelection = allLabel; + } + if (allSelected) + { + ImGui.SetItemDefaultFocus(); + } + + foreach (var option in options) + { + bool selected = string.Equals(currentSelection, option, StringComparison.OrdinalIgnoreCase); + if (ImGui.Selectable(option, selected)) + { + currentSelection = option; + } + if (selected) + { + ImGui.SetItemDefaultFocus(); + } + } + + ImGui.EndCombo(); + } + + private void DrawSummaryCell( + FontAwesomeIcon icon, + Vector4 iconColor, + string mainText, + string subText, + Vector4 subColor, + float scale, + string? extraText = null, + Vector4? extraColor = null, + string? tooltip = null) + { + ImGui.TableNextColumn(); + var spacing = new Vector2(6f * scale, 2f * scale); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, spacing)) + { + ImGui.BeginGroup(); + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) + { + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, subColor)) + { + ImGui.TextUnformatted(subText); + } + if (!string.IsNullOrWhiteSpace(extraText)) + { + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? subColor)) + { + ImGui.TextUnformatted(extraText); + } + } + ImGui.EndGroup(); + } + + if (!string.IsNullOrWhiteSpace(tooltip) && ImGui.IsItemHovered()) + { + ImGui.SetTooltip(tooltip); + } + } + + private void DrawOtherFileWorkspace(IReadOnlyList> otherFileGroups) + { + if (otherFileGroups.Count == 0) + { + return; + } + + var scale = ImGuiHelpers.GlobalScale; + + ImGuiHelpers.ScaledDummy(8); + var accent = UIColors.Get("LightlessBlue"); + var sectionAvail = ImGui.GetContentRegionAvail().Y; + IGrouping? activeGroup = null; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 4f * scale))) + using (var child = ImRaii.Child("otherFileTypes", new Vector2(-1f, sectionAvail - (SelectedFilePanelLogicalHeight * scale) - 12f * scale), true)) + { + if (child) + { + UiSharedService.ColorText("Other file types", UIColors.Get("LightlessPurple")); + ImGuiHelpers.ScaledDummy(4); + + using var tabBar = ImRaii.TabBar("otherFileTabs", ImGuiTabBarFlags.NoCloseWithMiddleMouseButton); + foreach (var fileGroup in otherFileGroups) + { + string tabLabel = $"{fileGroup.Key} [{fileGroup.Count()}]"; + var requiresCompute = fileGroup.Any(k => !k.IsComputed); + using var tabCol = ImRaii.PushColor(ImGuiCol.Tab, UiSharedService.Color(UIColors.Get("LightlessYellow")), requiresCompute); + if (requiresCompute) + { + tabLabel += " (!)"; + } + + ImRaii.IEndObject tabItem; + using (var textCol = ImRaii.PushColor(ImGuiCol.Text, UiSharedService.Color(new Vector4(0, 0, 0, 1)), + requiresCompute && !string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal))) + { + tabItem = ImRaii.TabItem(tabLabel + "###other_" + fileGroup.Key); + } + + if (!tabItem) + { + tabItem.Dispose(); + continue; + } + + activeGroup = fileGroup; + + if (!string.Equals(_selectedFileTypeTab, fileGroup.Key, StringComparison.Ordinal)) + { + _selectedFileTypeTab = fileGroup.Key; + _selectedHash = string.Empty; + } + + var originalTotal = fileGroup.Sum(c => c.OriginalSize); + var compressedTotal = fileGroup.Sum(c => c.CompressedSize); + + var badgeBg = new Vector4(accent.X, accent.Y, accent.Z, 0.18f); + var badgeBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var summaryHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 2.6f, 36f * scale); + var summaryWidth = MathF.Min(420f * scale, ImGui.GetContentRegionAvail().X); + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 4f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(badgeBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(badgeBorder))) + using (var summaryChild = ImRaii.Child($"otherFileSummary##{fileGroup.Key}", new Vector2(summaryWidth, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (summaryChild) + { + var infoColor = ImGuiColors.DalamudGrey; + var countColor = UIColors.Get("LightlessBlue"); + var actualColor = ImGuiColors.DalamudGrey; + var compressedColor = UIColors.Get("LightlessYellow2"); + + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(12f * scale, 2f * scale))) + { + using var summaryTable = ImRaii.Table("otherFileSummaryTable", 3, + ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX, + new Vector2(-1f, -1f)); + if (summaryTable) + { + ImGui.TableNextRow(); + DrawSummaryCell(FontAwesomeIcon.LayerGroup, countColor, + fileGroup.Count().ToString("N0", CultureInfo.InvariantCulture), + $"{fileGroup.Key} files", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.FileArchive, actualColor, + UiSharedService.ByteToString(originalTotal), + "Actual size", infoColor, scale); + DrawSummaryCell(FontAwesomeIcon.CompressArrowsAlt, compressedColor, + UiSharedService.ByteToString(compressedTotal), + "Compressed size", infoColor, scale); + } + } + } + } + + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(4f * scale, 3f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 3f * scale))) + { + DrawTable(fileGroup); + } + + tabItem.Dispose(); + } + } + } + + if (activeGroup == null && otherFileGroups.Count > 0) + { + activeGroup = otherFileGroups[0]; + } + + DrawSelectedFileDetails(activeGroup); + } + + private void DrawSelectedFileDetails(IGrouping? fileGroup) + { + var hasGroup = fileGroup != null; + var selectionInGroup = hasGroup && !string.IsNullOrEmpty(_selectedHash) && + fileGroup!.Any(entry => string.Equals(entry.Hash, _selectedHash, StringComparison.Ordinal)); + + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessBlue"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.12f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.3f); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 5f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(10f * scale, 6f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("selectedFileDetails", new Vector2(-1f, SelectedFilePanelLogicalHeight * scale), true)) + { + if (!child) + { + return; + } + + if (!selectionInGroup || + _cachedAnalysis == null || + !_cachedAnalysis.TryGetValue(_selectedObjectTab, out var objectEntries) || + !objectEntries.TryGetValue(_selectedHash, out var item)) + { + UiSharedService.ColorText("Select a file row to view details.", ImGuiColors.DalamudGrey); + return; + } + + UiSharedService.ColorText("Selected file:", UIColors.Get("LightlessBlue")); + ImGui.SameLine(); + UiSharedService.ColorText(_selectedHash, UIColors.Get("LightlessYellow")); + + ImGuiHelpers.ScaledDummy(2); + + UiSharedService.ColorText("Local file path:", UIColors.Get("LightlessBlue")); + ImGui.SameLine(); + UiSharedService.TextWrapped(item.FilePaths[0]); + if (item.FilePaths.Count > 1) + { + ImGui.SameLine(); + ImGui.TextUnformatted($"(and {item.FilePaths.Count - 1} more)"); + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); + UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.FilePaths.Skip(1))); + } + + UiSharedService.ColorText("Used by game path:", UIColors.Get("LightlessBlue")); + ImGui.SameLine(); + UiSharedService.TextWrapped(item.GamePaths[0]); + if (item.GamePaths.Count > 1) + { + ImGui.SameLine(); + ImGui.TextUnformatted($"(and {item.GamePaths.Count - 1} more)"); + ImGui.SameLine(); + _uiSharedService.IconText(FontAwesomeIcon.InfoCircle); + UiSharedService.AttachToolTip(string.Join(Environment.NewLine, item.GamePaths.Skip(1))); + } + } + } + + private TextureRow? DrawTextureTable(IReadOnlyList rows, long totalOriginal, long totalCompressed, bool hasAnyTextureRows) + { + var scale = ImGuiHelpers.GlobalScale; + if (rows.Count > 0) + { + var accent = UIColors.Get("LightlessBlue"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var originalColor = ImGuiColors.DalamudGrey; + var compressedColor = UIColors.Get("LightlessYellow2"); + var infoColor = ImGuiColors.DalamudGrey; + var countLabel = rows.Count == 1 ? "Matching texture" : "Matching textures"; + var diff = totalOriginal - totalCompressed; + var diffMagnitude = Math.Abs(diff); + var diffColor = diff > 0 ? UIColors.Get("LightlessGreen") + : diff < 0 ? UIColors.Get("DimRed") + : ImGuiColors.DalamudGrey; + var diffLabel = diff > 0 ? "Saved" : diff < 0 ? "Overhead" : "Lossless"; + var diffPercent = diffMagnitude > 0 && totalOriginal > 0 + ? ((diffMagnitude * 100d) / totalOriginal).ToString("0", CultureInfo.InvariantCulture) + "%" + : null; + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, new Vector2(12f * scale, 6f * scale))) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(18f * scale, 4f * scale))) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + { + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + var summaryHeight = MathF.Max(lineHeight * 2.4f, ImGui.GetFrameHeightWithSpacing() * 2.2f); + using (var summary = ImRaii.Child("textureCompressionSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (summary) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(8f * scale, 4f * scale))) + { + if (ImGui.BeginTable("textureCompressionSummaryTable", 4, ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.NoHostExtendX)) + { + ImGui.TableNextRow(); + DrawSummaryStat(FontAwesomeIcon.Images, accent, $"{rows.Count:N0}", countLabel, infoColor); + DrawSummaryStat(FontAwesomeIcon.FileArchive, originalColor, UiSharedService.ByteToString(totalOriginal), "Original total", infoColor); + DrawSummaryStat(FontAwesomeIcon.CompressArrowsAlt, compressedColor, UiSharedService.ByteToString(totalCompressed), "Compressed total", infoColor); + DrawSummaryStat( + FontAwesomeIcon.ChartLine, + diffColor, + diffMagnitude > 0 ? UiSharedService.ByteToString(diffMagnitude) : "No change", + diffLabel, + diffColor, + diffPercent, + diffColor); + + ImGui.EndTable(); + } + } + } + } + } + } + else + { + if (hasAnyTextureRows) + { + UiSharedService.TextWrapped("No textures match the current filters. Try clearing filters or refreshing the analysis session."); + } + else + { + UiSharedService.ColorText("No textures recorded for this object.", ImGuiColors.DalamudGrey); + } + } + + UiSharedService.TextWrapped("Mark textures using the checkbox to add them to the compression queue. You can adjust the target format for each texture before starting the batch compression."); + + bool conversionRunning = _conversionTask != null && !_conversionTask.IsCompleted; + var activeSelectionCount = _textureRows.Count(row => _selectedTextureKeys.Contains(row.Key) && !row.IsAlreadyCompressed); + bool hasSelection = activeSelectionCount > 0; + using (ImRaii.Disabled(conversionRunning || !hasSelection)) + { + var label = hasSelection ? $"Compress {activeSelectionCount} selected" : "Compress selected"; + if (_uiSharedService.IconTextButton(FontAwesomeIcon.CompressArrowsAlt, label, 220f * scale)) + { + StartTextureConversion(); + } + } + ImGui.SameLine(); + using (ImRaii.Disabled(_selectedTextureKeys.Count == 0 && _textureSelections.Count == 0)) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear marks", 160f * scale)) + { + _selectedTextureKeys.Clear(); + _textureSelections.Clear(); + } + } + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Sync, "Refresh", 130f * scale)) + { + _textureRowsDirty = true; + } + + TextureRow? lastSelected = null; + using (var table = ImRaii.Table("textureDataTable", 9, + ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.Sortable, + new Vector2(-1, 0))) + { + if (table) + { + ImGui.TableSetupColumn("##select", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 32f * scale); + ImGui.TableSetupColumn("Texture", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 310f * scale); + ImGui.TableSetupColumn("Slot", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("Map", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("Format", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("Recommended", ImGuiTableColumnFlags.NoSort); + ImGui.TableSetupColumn("Target", ImGuiTableColumnFlags.WidthFixed | ImGuiTableColumnFlags.NoSort, 140f * scale); + ImGui.TableSetupColumn("Original", ImGuiTableColumnFlags.PreferSortDescending); + ImGui.TableSetupColumn("Compressed", ImGuiTableColumnFlags.PreferSortDescending); + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableHeadersRow(); + + var targets = _textureCompressionService.SelectableTargets; + + IEnumerable orderedRows = rows; + var sortSpecs = ImGui.TableGetSortSpecs(); + if (sortSpecs.SpecsCount > 0) + { + var spec = sortSpecs.Specs[0]; + orderedRows = spec.ColumnIndex switch + { + 7 => spec.SortDirection == ImGuiSortDirection.Ascending + ? rows.OrderBy(r => r.OriginalSize) + : rows.OrderByDescending(r => r.OriginalSize), + 8 => spec.SortDirection == ImGuiSortDirection.Ascending + ? rows.OrderBy(r => r.CompressedSize) + : rows.OrderByDescending(r => r.CompressedSize), + _ => rows + }; + + sortSpecs.SpecsDirty = false; + } + + var index = 0; + foreach (var row in orderedRows) + { + DrawTextureRow(row, targets, index); + index++; + } + } + } + + return rows.FirstOrDefault(r => r.Key == _selectedTextureKey); + + void DrawSummaryStat(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string subText, Vector4 subColor, string? extraText = null, Vector4? extraColor = null) + { + ImGui.TableNextColumn(); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale))) + using (ImRaii.Group()) + { + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 6f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) + { + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, subColor)) + { + ImGui.TextUnformatted(subText); + } + if (!string.IsNullOrWhiteSpace(extraText)) + { + ImGui.SameLine(0f, 6f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? iconColor)) + { + ImGui.TextUnformatted(extraText); + } + } + } + } + } + private void StartTextureConversion() + { + if (_conversionTask != null && !_conversionTask.IsCompleted) + { + return; + } + + var selectedRows = _textureRows + .Where(row => _selectedTextureKeys.Contains(row.Key) && !row.IsAlreadyCompressed) + .ToList(); + if (selectedRows.Count == 0) + { + return; + } + + var requests = selectedRows.Select(row => + { + var desiredTarget = _textureSelections.TryGetValue(row.Key, out var selection) + ? selection + : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; + var normalizedTarget = _textureCompressionService.NormalizeTarget(desiredTarget); + _textureSelections[row.Key] = normalizedTarget; + + return new TextureCompressionRequest(row.PrimaryFilePath, row.DuplicateFilePaths, normalizedTarget); + }).ToList(); + + _conversionCancellationTokenSource = _conversionCancellationTokenSource.CancelRecreate(); + _conversionTotalJobs = requests.Count; + _lastConversionProgress = null; + _conversionCurrentFileName = string.Empty; + _conversionCurrentFileProgress = 0; + _conversionFailed = false; + + _conversionTask = RunTextureConversionAsync(requests, _conversionCancellationTokenSource.Token); + _showModal = true; + } + + private async Task RunTextureConversionAsync(List requests, CancellationToken token) + { + try + { + await _textureCompressionService.ConvertTexturesAsync(requests, _conversionProgress, token).ConfigureAwait(false); + + if (!token.IsCancellationRequested) + { + var affectedPaths = requests + .SelectMany(static request => + { + IEnumerable paths = request.DuplicateFilePaths; + return new[] { request.PrimaryFilePath }.Concat(paths); + }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (affectedPaths.Count > 0) + { + try + { + await _characterAnalyzer.UpdateFileEntriesAsync(affectedPaths, token).ConfigureAwait(false); + _hasUpdate = true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception updateEx) + { + _logger.LogWarning(updateEx, "Failed to refresh compressed size data after texture conversion."); + } + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Texture compression was cancelled."); + } + catch (Exception ex) + { + _conversionFailed = true; + _logger.LogError(ex, "Texture compression failed."); + } + finally + { + _selectedTextureKeys.Clear(); + _textureSelections.Clear(); + _textureRowsDirty = true; + } + } + + private void DrawVerticalResizeHandle(string id, float topY, float height, ref float leftWidth, float minWidth, float maxWidth, bool invert = false) + { + var scale = ImGuiHelpers.GlobalScale; + var splitterWidth = 8f * scale; + ImGui.SameLine(); + var cursor = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2(cursor.X, topY)); + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("ButtonDefault")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple")); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive")); + ImGui.Button(id, new Vector2(splitterWidth, height)); + ImGui.PopStyleColor(3); + + if (ImGui.IsItemActive()) + { + var delta = ImGui.GetIO().MouseDelta.X / scale; + leftWidth += invert ? -delta : delta; + leftWidth = Math.Clamp(leftWidth, minWidth, maxWidth); + } + ImGui.SetCursorPos(new Vector2(cursor.X + splitterWidth + ImGui.GetStyle().ItemSpacing.X, cursor.Y)); + } + + private (IDalamudTextureWrap? Texture, bool IsLoading, string? Error) GetTexturePreview(TextureRow row) + { + var key = row.Key; + if (!_texturePreviews.TryGetValue(key, out var state)) + { + state = new TexturePreviewState(); + _texturePreviews[key] = state; + } + + state.LastAccessUtc = DateTime.UtcNow; + + if (state.Texture != null) + { + return (state.Texture, false, state.ErrorMessage); + } + + if (state.LoadTask == null) + { + state.LoadTask = Task.Run(async () => + { + try + { + var texture = await BuildPreviewAsync(row, CancellationToken.None).ConfigureAwait(false); + state.Texture = texture; + state.ErrorMessage = texture == null ? "Preview unavailable." : null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load preview for {File}", row.PrimaryFilePath); + state.ErrorMessage = "Failed to load preview."; + } + }); + } + + if (state.LoadTask.IsCompleted) + { + state.LoadTask = null; + return (state.Texture, false, state.ErrorMessage); + } + + return (null, true, state.ErrorMessage); + } + + private async Task BuildPreviewAsync(TextureRow row, CancellationToken token) + { + if (!_ipcManager.Penumbra.APIAvailable) + { + return null; + } + + var tempFile = Path.Combine(Path.GetTempPath(), $"lightless_preview_{Guid.NewGuid():N}.png"); + try + { + var job = new TextureConversionJob(row.PrimaryFilePath, tempFile, TextureType.Png, IncludeMipMaps: false); + await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { job }, null, token).ConfigureAwait(false); + if (!File.Exists(tempFile)) + { + return null; + } + + var data = await File.ReadAllBytesAsync(tempFile, token).ConfigureAwait(false); + return _uiSharedService.LoadImage(data); + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Preview generation failed for {File}", row.PrimaryFilePath); + return null; + } + finally + { + try + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + catch (Exception ex) + { + _logger.LogTrace(ex, "Failed to clean up preview temp file {File}", tempFile); + } + } + } + + private void ResetPreview(string key) + { + if (_texturePreviews.TryGetValue(key, out var state)) + { + state.Texture?.Dispose(); + _texturePreviews.Remove(key); + } + } + + private void DrawTextureRow(TextureRow row, IReadOnlyList targets, int index) + { + var key = row.Key; + bool isSelected = string.Equals(_selectedTextureKey, key, StringComparison.Ordinal); + + ImGui.TableNextRow(ImGuiTableRowFlags.None, 0); + ApplyTextureRowBackground(row, isSelected); + + bool canCompress = !row.IsAlreadyCompressed; + if (!canCompress) + { + _selectedTextureKeys.Remove(key); + _textureSelections.Remove(key); + } + + ImGui.TableNextColumn(); + if (canCompress) + { + bool marked = _selectedTextureKeys.Contains(key); + if (UiSharedService.CheckboxWithBorder($"##select{index}", ref marked, UIColors.Get("LightlessPurple"), 1.5f)) + { + if (marked) + { + _selectedTextureKeys.Add(key); + } + else + { + _selectedTextureKeys.Remove(key); + } + } + UiSharedService.AttachToolTip("Mark texture for batch compression."); + } + else + { + ImGui.TextDisabled("-"); + UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled."); + } + + DrawSelectableColumn(isSelected, () => + { + var selectableLabel = $"{row.DisplayName}##texName{index}"; + if (ImGui.Selectable(selectableLabel, isSelected)) + { + _selectedTextureKey = isSelected ? string.Empty : key; + } + + return () => UiSharedService.AttachToolTip($"{row.PrimaryFilePath}{UiSharedService.TooltipSeparator}{string.Join(Environment.NewLine, row.GamePaths)}"); + }); + + DrawSelectableColumn(isSelected, () => + { + ImGui.TextUnformatted(row.Slot); + return null; + }); + DrawSelectableColumn(isSelected, () => + { + ImGui.TextUnformatted(row.MapKind.ToString()); + return null; + }); + DrawSelectableColumn(isSelected, () => + { + ImGui.TextUnformatted(row.Format); + return null; + }); + + DrawSelectableColumn(isSelected, () => + { + if (row.SuggestedTarget.HasValue) + { + ImGui.TextUnformatted(row.SuggestedTarget.Value.ToString()); + if (!string.IsNullOrEmpty(row.SuggestionReason)) + { + var reason = row.SuggestionReason; + return () => UiSharedService.AttachToolTip(reason); + } + } + else + { + ImGui.TextUnformatted("-"); + } + + return null; + }); + + ImGui.TableNextColumn(); + if (canCompress) + { + var desiredTarget = _textureSelections.TryGetValue(key, out var storedTarget) + ? storedTarget + : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; + var currentSelection = _textureCompressionService.NormalizeTarget(desiredTarget); + if (!_textureSelections.TryGetValue(key, out _) || storedTarget != currentSelection) + { + _textureSelections[key] = currentSelection; + } + var comboLabel = currentSelection.ToString(); + ImGui.SetNextItemWidth(-1); + if (ImGui.BeginCombo($"##target{index}", comboLabel)) + { + foreach (var target in targets) + { + bool targetSelected = currentSelection == target; + if (ImGui.Selectable(target.ToString(), targetSelected)) + { + _textureSelections[key] = target; + currentSelection = target; + } + if (targetSelected) + { + ImGui.SetItemDefaultFocus(); + } + } + + ImGui.EndCombo(); + } + } + else + { + var label = row.CurrentTarget?.ToString() ?? row.Format; + ImGui.TextUnformatted(label); + UiSharedService.AttachToolTip("This texture is already compressed and cannot be processed again."); + } + + DrawSelectableColumn(isSelected, () => + { + ImGui.TextUnformatted(UiSharedService.ByteToString(row.OriginalSize)); + return null; + }); + DrawSelectableColumn(isSelected, () => + { + ImGui.TextUnformatted(UiSharedService.ByteToString(row.CompressedSize)); + return null; + }); + } + + private static void DrawSelectableColumn(bool isSelected, Func draw) + { + ImGui.TableNextColumn(); + if (isSelected) + { + ImGui.PushStyleColor(ImGuiCol.Text, SelectedTextureRowTextColor); + } + + var after = draw(); + + if (isSelected) + { + ImGui.PopStyleColor(); + } + + after?.Invoke(); + } + + private static void ApplyTextureRowBackground(TextureRow row, bool isSelected) + { + if (isSelected) + { + var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); + } + else if (row.IsAlreadyCompressed) + { + var compressedColor = UiSharedService.Color(UIColors.Get("LightlessGreenDefault")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, compressedColor); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, compressedColor); + } + else if (!row.IsComputed) + { + var warning = UiSharedService.Color(UIColors.Get("DimRed")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); + } + } + + private void DrawTextureDetail(TextureRow? row) + { + var scale = ImGuiHelpers.GlobalScale; + + UiSharedService.ColorText("Texture Details", UIColors.Get("LightlessPurple")); + if (row != null) + { + ImGui.SameLine(); + ImGui.TextUnformatted(row.DisplayName); + UiSharedService.AttachToolTip("Source file: " + row.PrimaryFilePath); + } + ImGui.Separator(); + + if (row == null) + { + UiSharedService.ColorText("Select a texture to view details.", ImGuiColors.DalamudGrey); + return; + } + + var (previewTexture, previewLoading, previewError) = GetTexturePreview(row); + var previewSize = new Vector2(_texturePreviewSize * 0.85f, _texturePreviewSize * 0.85f) * scale; + + if (previewTexture != null) + { + ImGui.Image(previewTexture.Handle, previewSize); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Refresh preview", 180f * ImGuiHelpers.GlobalScale)) + { + ResetPreview(row.Key); + } + } + else + { + using (ImRaii.Child("previewPlaceholder", previewSize, true)) + { + UiSharedService.TextWrapped(previewLoading ? "Generating preview..." : previewError ?? "Preview unavailable."); + } + if (!previewLoading && _uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Retry preview", 180f * ImGuiHelpers.GlobalScale)) + { + ResetPreview(row.Key); + } + } + + var desiredDetailTarget = _textureSelections.TryGetValue(row.Key, out var userTarget) + ? userTarget + : row.SuggestedTarget ?? row.CurrentTarget ?? _textureCompressionService.DefaultTarget; + var selectedTarget = _textureCompressionService.NormalizeTarget(desiredDetailTarget); + if (!_textureSelections.TryGetValue(row.Key, out _) || userTarget != selectedTarget) + { + _textureSelections[row.Key] = selectedTarget; + } + var hasSelectedInfo = _textureMetadataHelper.TryGetRecommendationInfo(selectedTarget, out var selectedInfo); + + using (ImRaii.Child("textureDetailInfo", new Vector2(-1, 0), true, ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + using var detailSpacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(6f * scale, 2f * scale)); + DrawMetaOverview(); + + ImGuiHelpers.ScaledDummy(4); + DrawSizeSummary(); + + ImGuiHelpers.ScaledDummy(4); + DrawCompressionInsights(); + + ImGuiHelpers.ScaledDummy(6); + DrawExpandableList("Duplicate Files", row.DuplicateFilePaths, "No duplicate files detected."); + DrawExpandableList("Game Paths", row.GamePaths, "No game paths recorded."); + } + + void DrawMetaOverview() + { + var labelColor = ImGuiColors.DalamudGrey; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(5f * scale, 2f * scale))) + { + var metaFlags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; + if (ImGui.BeginTable("textureMetaOverview", 2, metaFlags)) + { + MetaRow(FontAwesomeIcon.Cube, "Object", row.ObjectKind.ToString()); + MetaRow(FontAwesomeIcon.Tag, "Slot", row.Slot); + MetaRow(FontAwesomeIcon.LayerGroup, "Map Type", row.MapKind.ToString()); + MetaRow(FontAwesomeIcon.Fingerprint, "Hash", row.Hash, UIColors.Get("LightlessBlue")); + MetaRow(FontAwesomeIcon.InfoCircle, "Current Format", row.Format); + + var selectedLabel = hasSelectedInfo ? selectedInfo!.Title : selectedTarget.ToString(); + var selectionColor = hasSelectedInfo ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessGreen"); + MetaRow(FontAwesomeIcon.Bullseye, "Selected Target", selectedLabel, selectionColor); + + ImGui.EndTable(); + } + } + + void MetaRow(FontAwesomeIcon icon, string label, string value, Vector4? valueColor = null) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + _uiSharedService.IconText(icon, labelColor); + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, labelColor)) + { + ImGui.TextUnformatted(label); + } + + ImGui.TableNextColumn(); + if (valueColor.HasValue) + { + using (ImRaii.PushColor(ImGuiCol.Text, valueColor.Value)) + { + ImGui.TextUnformatted(value); + } + } + else + { + ImGui.TextUnformatted(value); + } + } + } + + void DrawSizeSummary() + { + var savedBytes = row.OriginalSize - row.CompressedSize; + var savedMagnitude = Math.Abs(savedBytes); + var savedColor = savedBytes > 0 ? UIColors.Get("LightlessGreen") : savedBytes < 0 ? UIColors.Get("DimRed") : ImGuiColors.DalamudGrey; + var savedLabel = savedBytes > 0 ? "Saved" : savedBytes < 0 ? "Over" : "Delta"; + var savedPercent = row.OriginalSize > 0 && savedMagnitude > 0 + ? $"{savedMagnitude * 100d / row.OriginalSize:0.#}%" + : null; + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + { + var statFlags = ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; + if (ImGui.BeginTable("textureSizeSummary", 3, statFlags)) + { + ImGui.TableNextRow(); + StatCell(FontAwesomeIcon.Database, ImGuiColors.DalamudGrey, UiSharedService.ByteToString(row.OriginalSize), "Original"); + StatCell(FontAwesomeIcon.CompressArrowsAlt, UIColors.Get("LightlessYellow2"), UiSharedService.ByteToString(row.CompressedSize), "Compressed"); + StatCell(FontAwesomeIcon.ChartLine, savedColor, savedMagnitude > 0 ? UiSharedService.ByteToString(savedMagnitude) : "No change", savedLabel, savedPercent, savedColor); + ImGui.EndTable(); + } + } + + void StatCell(FontAwesomeIcon icon, Vector4 iconColor, string mainText, string caption, string? extra = null, Vector4? extraColor = null) + { + ImGui.TableNextColumn(); + _uiSharedService.IconText(icon, iconColor); + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, iconColor)) + { + ImGui.TextUnformatted(mainText); + } + using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey)) + { + ImGui.TextUnformatted(caption); + } + if (!string.IsNullOrEmpty(extra)) + { + ImGui.SameLine(0f, 4f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, extraColor ?? iconColor)) + { + ImGui.TextUnformatted(extra); + } + } + } + } + + void DrawCompressionInsights() + { + var matchesRecommendation = row.SuggestedTarget.HasValue && row.SuggestedTarget.Value == selectedTarget; + var columnCount = row.SuggestedTarget.HasValue ? 2 : 1; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + { + var cardFlags = ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.PadOuterX; + if (ImGui.BeginTable("textureCompressionCards", columnCount, cardFlags)) + { + ImGui.TableNextRow(); + var selectedTitleColor = matchesRecommendation ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessBlue"); + var selectedDescription = hasSelectedInfo + ? selectedInfo!.Description + : matchesRecommendation + ? "Selected target matches the automatic recommendation." + : "Manual selection without additional guidance."; + DrawCompressionCard("Selected Target", selectedLabelText(), selectedTitleColor, selectedDescription); + + if (row.SuggestedTarget.HasValue) + { + var recommendedTarget = row.SuggestedTarget.Value; + var hasRecommendationInfo = _textureMetadataHelper.TryGetRecommendationInfo(recommendedTarget, out var recommendedInfo); + var recommendedTitle = hasRecommendationInfo ? recommendedInfo!.Title : recommendedTarget.ToString(); + var recommendedDescription = hasRecommendationInfo + ? recommendedInfo!.Description + : string.IsNullOrEmpty(row.SuggestionReason) ? "No additional context provided." : row.SuggestionReason; + + DrawCompressionCard("Recommended Target", recommendedTitle, UIColors.Get("LightlessYellow"), recommendedDescription); + } + + ImGui.EndTable(); + } + } + + using (ImRaii.PushIndent(12f * scale)) + { + if (!row.SuggestedTarget.HasValue) + { + UiSharedService.ColorTextWrapped("No automatic recommendation available.", UIColors.Get("LightlessYellow")); + } + + if (!matchesRecommendation && row.SuggestedTarget.HasValue) + { + UiSharedService.ColorTextWrapped("Selected compression differs from the recommendation. Review before running batch conversion.", UIColors.Get("DimRed")); + } + } + + string selectedLabelText() + { + if (hasSelectedInfo) + { + return selectedInfo!.Title; + } + + return selectedTarget.ToString(); + } + + void DrawCompressionCard(string header, string title, Vector4 titleColor, string body) + { + ImGui.TableNextColumn(); + UiSharedService.ColorText(header, UIColors.Get("LightlessPurple")); + using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) + { + ImGui.TextUnformatted(title); + } + UiSharedService.TextWrapped(body, 0, ImGuiColors.DalamudGrey); + } + } + + void DrawExpandableList(string title, IReadOnlyList entries, string emptyMessage) + { + _ = emptyMessage; + var count = entries.Count; + using var headerDefault = ImRaii.PushColor(ImGuiCol.Header, UiSharedService.Color(new Vector4(0.15f, 0.15f, 0.18f, 0.95f))); + using var headerHover = ImRaii.PushColor(ImGuiCol.HeaderHovered, UiSharedService.Color(new Vector4(0.2f, 0.2f, 0.25f, 1f))); + using var headerActive = ImRaii.PushColor(ImGuiCol.HeaderActive, UiSharedService.Color(new Vector4(0.25f, 0.25f, 0.3f, 1f))); + var label = $"{title} ({count})"; + if (!ImGui.CollapsingHeader(label, count == 0 ? ImGuiTreeNodeFlags.Leaf : ImGuiTreeNodeFlags.None)) + { + return; + } + + if (count == 0) + { + return; + } + + var tableFlags = ImGuiTableFlags.PadOuterX | ImGuiTableFlags.NoHostExtendX | ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.BordersOuter; + if (ImGui.BeginTable($"{title}Table", 2, tableFlags)) + { + ImGui.TableSetupColumn("#", ImGuiTableColumnFlags.WidthFixed, 28f * scale); + ImGui.TableSetupColumn("Path", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableHeadersRow(); + + for (int i = 0; i < entries.Count; i++) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted($"{i + 1}."); + + ImGui.TableNextColumn(); + var wrapPos = ImGui.GetCursorPosX() + ImGui.GetColumnWidth(); + ImGui.PushTextWrapPos(wrapPos); + ImGui.TextUnformatted(entries[i]); + ImGui.PopTextWrapPos(); + } + + ImGui.EndTable(); + } + } + } + + private void SortCachedAnalysis( + ObjectKind objectKind, + Func, TKey> selector, + bool ascending, + IComparer? comparer = null) + { + if (_cachedAnalysis == null || !_cachedAnalysis.TryGetValue(objectKind, out var current)) + { + return; + } + + var ordered = ascending + ? (comparer != null ? current.OrderBy(selector, comparer) : current.OrderBy(selector)) + : (comparer != null ? current.OrderByDescending(selector, comparer) : current.OrderByDescending(selector)); + + _cachedAnalysis[objectKind] = ordered.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); } private void DrawTable(IGrouping fileGroup) { - var tableColumns = string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal) - ? (_enableBc7ConversionMode ? 7 : 6) - : (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) ? 6 : 5); - using var table = ImRaii.Table("Analysis", tableColumns, ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, - new Vector2(0, 300)); - if (!table.Success) return; + var tableColumns = string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) ? 6 : 5; + var scale = ImGuiHelpers.GlobalScale; + using var table = ImRaii.Table("Analysis", tableColumns, + ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.BordersInnerV, + new Vector2(-1f, 0f)); + if (!table.Success) + { + return; + } + ImGui.TableSetupColumn("Hash"); ImGui.TableSetupColumn("Filepaths"); ImGui.TableSetupColumn("Gamepaths"); ImGui.TableSetupColumn("Original Size"); ImGui.TableSetupColumn("Compressed Size"); - if (string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal)) - { - ImGui.TableSetupColumn("Format"); - if (_enableBc7ConversionMode) ImGui.TableSetupColumn("Convert to BC7"); - } if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) { ImGui.TableSetupColumn("Triangles"); } + ImGui.TableSetupScrollFreeze(0, 1); ImGui.TableHeadersRow(); var sortSpecs = ImGui.TableGetSortSpecs(); - if (sortSpecs.SpecsDirty) + if (sortSpecs.SpecsDirty && sortSpecs.SpecsCount > 0) { - var idx = sortSpecs.Specs.ColumnIndex; + var spec = sortSpecs.Specs[0]; + bool ascending = spec.SortDirection == ImGuiSortDirection.Ascending; - if (idx == 0 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Key, StringComparer.Ordinal).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 0 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Key, StringComparer.Ordinal).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 1 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.FilePaths.Count).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 1 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.FilePaths.Count).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 2 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.GamePaths.Count).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 2 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.GamePaths.Count).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 3 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.OriginalSize).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 3 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.OriginalSize).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 4 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.CompressedSize).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (idx == 4 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.CompressedSize).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) && idx == 5 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.Triangles).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) && idx == 5 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.Triangles).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal) && idx == 5 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Ascending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderBy(k => k.Value.Format.Value, StringComparer.Ordinal).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); - if (string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal) && idx == 5 && sortSpecs.Specs.SortDirection == ImGuiSortDirection.Descending) - _cachedAnalysis![_selectedObjectTab] = _cachedAnalysis[_selectedObjectTab].OrderByDescending(k => k.Value.Format.Value, StringComparer.Ordinal).ToDictionary(d => d.Key, d => d.Value, StringComparer.Ordinal); + switch (spec.ColumnIndex) + { + case 0: + SortCachedAnalysis(_selectedObjectTab, pair => pair.Key, ascending, StringComparer.Ordinal); + break; + case 1: + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.FilePaths.Count, ascending); + break; + case 2: + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.GamePaths.Count, ascending); + break; + case 3: + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.OriginalSize, ascending); + break; + case 4: + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.CompressedSize, ascending); + break; + case 5 when string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal): + SortCachedAnalysis(_selectedObjectTab, pair => pair.Value.Triangles, ascending); + break; + } sortSpecs.SpecsDirty = false; } foreach (var item in fileGroup) { - using var text = ImRaii.PushColor(ImGuiCol.Text, new Vector4(0, 0, 0, 1), string.Equals(item.Hash, _selectedHash, StringComparison.Ordinal)); - using var text2 = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 1), !item.IsComputed); + using var textColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(0, 0, 0, 1), string.Equals(item.Hash, _selectedHash, StringComparison.Ordinal)); + using var missingColor = ImRaii.PushColor(ImGuiCol.Text, new Vector4(1, 1, 1, 1), !item.IsComputed); ImGui.TableNextColumn(); if (!item.IsComputed) { - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, UiSharedService.Color(ImGuiColors.DalamudRed)); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, UiSharedService.Color(ImGuiColors.DalamudRed)); + var warning = UiSharedService.Color(UIColors.Get("DimRed")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, warning); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, warning); } if (string.Equals(_selectedHash, item.Hash, StringComparison.Ordinal)) { - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, UiSharedService.Color(UIColors.Get("LightlessYellow"))); - ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, UiSharedService.Color(UIColors.Get("LightlessYellow"))); + var highlight = UiSharedService.Color(UIColors.Get("LightlessYellow")); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg1, highlight); + ImGui.TableSetBgColor(ImGuiTableBgTarget.RowBg0, highlight); } ImGui.TextUnformatted(item.Hash); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; + if (ImGui.IsItemClicked()) + { + _selectedHash = string.Equals(_selectedHash, item.Hash, StringComparison.Ordinal) + ? string.Empty + : item.Hash; + } + ImGui.TableNextColumn(); ImGui.TextUnformatted(item.FilePaths.Count.ToString()); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; + ImGui.TableNextColumn(); ImGui.TextUnformatted(item.GamePaths.Count.ToString()); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; + ImGui.TableNextColumn(); ImGui.TextUnformatted(UiSharedService.ByteToString(item.OriginalSize)); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; + ImGui.TableNextColumn(); ImGui.TextUnformatted(UiSharedService.ByteToString(item.CompressedSize)); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; - if (string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal)) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted(item.Format.Value); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; - if (_enableBc7ConversionMode) - { - ImGui.TableNextColumn(); - if (string.Equals(item.Format.Value, "BC7", StringComparison.Ordinal)) - { - ImGui.TextUnformatted(""); - continue; - } - var filePath = item.FilePaths[0]; - bool toConvert = _texturesToConvert.ContainsKey(filePath); - if (UiSharedService.CheckboxWithBorder("###convert" + item.Hash, ref toConvert, UIColors.Get("LightlessPurple"), 1.5f)) - { - if (toConvert && !_texturesToConvert.ContainsKey(filePath)) - { - _texturesToConvert[filePath] = item.FilePaths.Skip(1).ToArray(); - } - else if (!toConvert && _texturesToConvert.ContainsKey(filePath)) - { - _texturesToConvert.Remove(filePath); - } - } - } - } + if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) { ImGui.TableNextColumn(); ImGui.TextUnformatted(item.Triangles.ToString()); - if (ImGui.IsItemClicked()) _selectedHash = item.Hash; } } } diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index d1410ad..1ecf3f5 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -1,14 +1,21 @@ -using LightlessSync.API.Dto.Group; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; +using LightlessSync.PlayerData.Factories; +using LightlessSync.PlayerData.Pairs; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Components; using LightlessSync.UI.Handlers; +using LightlessSync.UI.Models; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; -using System.Collections.Immutable; namespace LightlessSync.UI; @@ -19,6 +26,7 @@ public class DrawEntityFactory private readonly LightlessMediator _mediator; private readonly SelectPairForTagUi _selectPairForTagUi; private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly LightlessConfigService _configService; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly CharaDataManager _charaDataManager; @@ -29,13 +37,28 @@ public class DrawEntityFactory private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly TagHandler _tagHandler; private readonly IdDisplayHandler _uidDisplayHandler; + private readonly PairLedger _pairLedger; + private readonly PairFactory _pairFactory; - public DrawEntityFactory(ILogger logger, ApiController apiController, IdDisplayHandler uidDisplayHandler, - SelectTagForPairUi selectTagForPairUi, RenamePairTagUi renamePairTagUi, LightlessMediator mediator, - TagHandler tagHandler, SelectPairForTagUi selectPairForTagUi, - ServerConfigurationManager serverConfigurationManager, UiSharedService uiSharedService, - PlayerPerformanceConfigService playerPerformanceConfigService, CharaDataManager charaDataManager, - SelectTagForSyncshellUi selectTagForSyncshellUi, RenameSyncshellTagUi renameSyncshellTagUi, SelectSyncshellForTagUi selectSyncshellForTagUi) + public DrawEntityFactory( + ILogger logger, + ApiController apiController, + IdDisplayHandler uidDisplayHandler, + SelectTagForPairUi selectTagForPairUi, + RenamePairTagUi renamePairTagUi, + LightlessMediator mediator, + TagHandler tagHandler, + SelectPairForTagUi selectPairForTagUi, + ServerConfigurationManager serverConfigurationManager, + LightlessConfigService configService, + UiSharedService uiSharedService, + PlayerPerformanceConfigService playerPerformanceConfigService, + CharaDataManager charaDataManager, + SelectTagForSyncshellUi selectTagForSyncshellUi, + RenameSyncshellTagUi renameSyncshellTagUi, + SelectSyncshellForTagUi selectSyncshellForTagUi, + PairLedger pairLedger, + PairFactory pairFactory) { _logger = logger; _apiController = apiController; @@ -46,44 +69,151 @@ public class DrawEntityFactory _tagHandler = tagHandler; _selectPairForTagUi = selectPairForTagUi; _serverConfigurationManager = serverConfigurationManager; + _configService = configService; _uiSharedService = uiSharedService; _playerPerformanceConfigService = playerPerformanceConfigService; _charaDataManager = charaDataManager; _selectTagForSyncshellUi = selectTagForSyncshellUi; _renameSyncshellTagUi = renameSyncshellTagUi; _selectSyncshellForTagUi = selectSyncshellForTagUi; + _pairLedger = pairLedger; + _pairFactory = pairFactory; } - public DrawFolderGroup CreateDrawGroupFolder(GroupFullInfoDto groupFullInfoDto, - Dictionary> filteredPairs, - IImmutableList allPairs) + public DrawFolderGroup CreateGroupFolder( + string id, + GroupFullInfoDto groupFullInfo, + IEnumerable drawEntries, + IEnumerable allEntries) { - return new DrawFolderGroup(groupFullInfoDto.Group.GID, groupFullInfoDto, _apiController, - filteredPairs.Select(p => CreateDrawPair(groupFullInfoDto.Group.GID + p.Key.UserData.UID, p.Key, p.Value, groupFullInfoDto)).ToImmutableList(), - allPairs, _tagHandler, _uidDisplayHandler, _mediator, _uiSharedService, _selectTagForSyncshellUi); + var drawPairs = drawEntries + .Select(entry => CreateDrawPair($"{id}:{entry.DisplayEntry.Ident.UserId}", entry, groupFullInfo)) + .Where(draw => draw is not null) + .Cast() + .ToImmutableList(); + + var allPairs = allEntries.ToImmutableList(); + + return new DrawFolderGroup( + id, + groupFullInfo, + _apiController, + drawPairs, + allPairs, + _tagHandler, + _uidDisplayHandler, + _mediator, + _uiSharedService, + _selectTagForSyncshellUi); } - public DrawFolderGroup CreateDrawGroupFolder(string id, GroupFullInfoDto groupFullInfoDto, - Dictionary> filteredPairs, - IImmutableList allPairs) + public DrawFolderTag CreateTagFolder( + string tag, + IEnumerable drawEntries, + IEnumerable allEntries) { - return new DrawFolderGroup(id, groupFullInfoDto, _apiController, - filteredPairs.Select(p => CreateDrawPair(groupFullInfoDto.Group.GID + p.Key.UserData.UID, p.Key, p.Value, groupFullInfoDto)).ToImmutableList(), - allPairs, _tagHandler, _uidDisplayHandler, _mediator, _uiSharedService, _selectTagForSyncshellUi); + var drawPairs = drawEntries + .Select(entry => CreateDrawPair($"{tag}:{entry.DisplayEntry.Ident.UserId}", entry)) + .Where(draw => draw is not null) + .Cast() + .ToImmutableList(); + + var allPairs = allEntries.ToImmutableList(); + + return new DrawFolderTag( + tag, + drawPairs, + allPairs, + _tagHandler, + _apiController, + _selectPairForTagUi, + _renamePairTagUi, + _uiSharedService, + _serverConfigurationManager, + _configService, + _mediator); } - public DrawFolderTag CreateDrawTagFolder(string tag, - Dictionary> filteredPairs, - IImmutableList allPairs) + public DrawUserPair? CreateDrawPair( + string id, + PairUiEntry entry, + GroupFullInfoDto? currentGroup = null) { - return new(tag, filteredPairs.Select(u => CreateDrawPair(tag, u.Key, u.Value, currentGroup: null)).ToImmutableList(), - allPairs, _tagHandler, _apiController, _selectPairForTagUi, _renamePairTagUi, _uiSharedService); + var pair = _pairFactory.Create(entry.DisplayEntry); + if (pair is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Skipping draw pair for {UserId}: legacy pair not found.", entry.DisplayEntry.Ident.UserId); + } + return null; + } + + return new DrawUserPair( + id, + entry, + pair, + currentGroup, + _apiController, + _uidDisplayHandler, + _mediator, + _selectTagForPairUi, + _serverConfigurationManager, + _uiSharedService, + _playerPerformanceConfigService, + _charaDataManager, + _pairLedger); } - public DrawUserPair CreateDrawPair(string id, Pair user, List groups, GroupFullInfoDto? currentGroup) + public IReadOnlyList GetAllEntries() { - return new DrawUserPair(id + user.UserData.UID, user, groups, currentGroup, _apiController, _uidDisplayHandler, - _mediator, _selectTagForPairUi, _serverConfigurationManager, _uiSharedService, _playerPerformanceConfigService, - _charaDataManager); + try + { + return _pairLedger.GetAllEntries() + .Select(BuildUiEntry) + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to build pair display entries."); + return Array.Empty(); + } + } + + private PairUiEntry BuildUiEntry(PairDisplayEntry entry) + { + var handler = entry.Handler; + var alias = entry.User.AliasOrUID; + if (string.IsNullOrWhiteSpace(alias)) + { + alias = entry.Ident.UserId; + } + + var displayName = !string.IsNullOrWhiteSpace(handler?.PlayerName) + ? handler!.PlayerName! + : alias; + + var note = _serverConfigurationManager.GetNoteForUid(entry.Ident.UserId) ?? string.Empty; + var isPaused = entry.SelfPermissions.IsPaused(); + + return new PairUiEntry( + entry, + alias, + displayName, + note, + entry.IsVisible, + entry.IsOnline, + entry.IsDirectlyPaired, + entry.IsOneSided, + entry.HasAnyConnection, + isPaused, + entry.SelfPermissions, + entry.OtherPermissions, + entry.PairStatus, + handler?.LastAppliedDataBytes ?? -1, + handler?.LastAppliedDataTris ?? -1, + handler?.LastAppliedApproximateVRAMBytes ?? -1, + handler?.LastAppliedApproximateEffectiveVRAMBytes ?? -1, + handler); } } \ No newline at end of file diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 17bc871..f965181 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -5,7 +5,6 @@ using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; @@ -17,6 +16,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using System.Runtime.InteropServices; using System.Text; +using LightlessSync.UI.Services; +using LightlessSync.PlayerData.Pairs; using static LightlessSync.Services.PairRequestService; namespace LightlessSync.UI; @@ -37,7 +38,7 @@ public sealed class DtrEntry : IDisposable, IHostedService private readonly BroadcastService _broadcastService; private readonly BroadcastScannerService _broadcastScannerService; private readonly LightlessMediator _lightlessMediator; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly PairRequestService _pairRequestService; private readonly DalamudUtilService _dalamudUtilService; private Task? _runTask; @@ -57,7 +58,7 @@ public sealed class DtrEntry : IDisposable, IHostedService IDtrBar dtrBar, ConfigurationServiceBase configService, LightlessMediator lightlessMediator, - PairManager pairManager, + PairUiService pairUiService, PairRequestService pairRequestService, ApiController apiController, ServerConfigurationManager serverManager, @@ -71,7 +72,7 @@ public sealed class DtrEntry : IDisposable, IHostedService _lightfinderEntry = new(CreateLightfinderEntry); _configService = configService; _lightlessMediator = lightlessMediator; - _pairManager = pairManager; + _pairUiService = pairUiService; _pairRequestService = pairRequestService; _apiController = apiController; _serverManager = serverManager; @@ -165,7 +166,7 @@ public sealed class DtrEntry : IDisposable, IHostedService entry.OnClick = interactionEvent => OnLightfinderEntryClick(interactionEvent); return entry; } - + private void OnStatusEntryClick(DtrInteractionEvent interactionEvent) { if (interactionEvent.ClickType.Equals(MouseClickType.Left)) @@ -254,16 +255,15 @@ public sealed class DtrEntry : IDisposable, IHostedService if (_apiController.IsConnected) { - var pairCount = _pairManager.GetVisibleUserCount(); + var snapshot = _pairUiService.GetSnapshot(); + var visiblePairsQuery = snapshot.PairsByUid.Values.Where(x => x.IsVisible && !x.IsPaused); + var pairCount = visiblePairsQuery.Count(); text = $"\uE044 {pairCount}"; if (pairCount > 0) { var preferNote = config.PreferNoteInDtrTooltip; var showUid = config.ShowUidInDtrTooltip; - var visiblePairsQuery = _pairManager.GetOnlineUserPairs() - .Where(x => x.IsVisible); - IEnumerable visiblePairs = showUid ? visiblePairsQuery.Select(x => string.Format("{0} ({1})", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName, x.UserData.AliasOrUID)) : visiblePairsQuery.Select(x => string.Format("{0}", preferNote ? x.GetNote() ?? x.PlayerName : x.PlayerName)); diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs new file mode 100644 index 0000000..57f6c2f --- /dev/null +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -0,0 +1,701 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data; +using LightlessSync.API.Dto.Group; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Tags; +using LightlessSync.Utils; +using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.PixelFormats; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; + +namespace LightlessSync.UI; + +public partial class EditProfileUi +{ + private void OpenGroupEditor(GroupFullInfoDto groupInfo) + { + _mode = ProfileEditorMode.Group; + _groupInfo = groupInfo; + + var profile = _lightlessProfileManager.GetLightlessGroupProfile(groupInfo.Group); + _groupProfileData = profile; + SyncGroupProfileState(profile, resetSelection: true); + + var scale = ImGuiHelpers.GlobalScale; + var viewport = ImGui.GetMainViewport(); + ProfileEditorLayoutCoordinator.Enable(groupInfo.Group.GID); + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + Mediator.Publish(new GroupProfileOpenStandaloneMessage(groupInfo)); + + IsOpen = true; + _wasOpen = true; + } + + private void ResetGroupEditorState() + { + _groupInfo = null; + _groupProfileData = null; + _groupIsNsfw = false; + _groupIsDisabled = false; + _groupServerIsNsfw = false; + _groupServerIsDisabled = false; + _queuedProfileImage = null; + _queuedBannerImage = null; + _profileImage = Array.Empty(); + _bannerImage = Array.Empty(); + _profileDescription = string.Empty; + _descriptionText = string.Empty; + _profileTagIds = Array.Empty(); + _tagEditorSelection.Clear(); + _pfpTextureWrap?.Dispose(); + _pfpTextureWrap = null; + _bannerTextureWrap?.Dispose(); + _bannerTextureWrap = null; + _showProfileImageError = false; + _showBannerImageError = false; + } + + private void DrawGroupEditor(float scale) + { + if (_groupInfo is null) + { + UiSharedService.TextWrapped("Open the Syncshell admin panel and choose a group to edit its profile."); + return; + } + + var viewport = ImGui.GetMainViewport(); + var linked = ProfileEditorLayoutCoordinator.IsActive(_groupInfo.Group.GID); + + if (linked) + { + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + + var desiredSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); + + var currentPos = ImGui.GetWindowPos(); + if (IsWindowBeingDragged()) + ProfileEditorLayoutCoordinator.UpdateAnchorFromEditor(currentPos, scale); + + var desiredPos = ProfileEditorLayoutCoordinator.GetEditorPosition(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) + ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); + } + else + { + var defaultProfilePos = viewport.WorkPos + new Vector2(50f, 70f) * scale; + var defaultEditorPos = defaultProfilePos + ProfileEditorLayoutCoordinator.GetEditorOffset(scale); + ImGui.SetWindowPos(defaultEditorPos, ImGuiCond.FirstUseEver); + } + + if (_queuedProfileImage is not null) + ApplyQueuedGroupProfileImage(); + if (_queuedBannerImage is not null) + ApplyQueuedGroupBannerImage(); + + var profile = _lightlessProfileManager.GetLightlessGroupProfile(_groupInfo.Group); + _groupProfileData = profile; + SyncGroupProfileState(profile, resetSelection: false); + + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.015f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); + + using var panelBg = ImRaii.PushColor(ImGuiCol.ChildBg, accentBg); + using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + + if (ImGui.BeginChild("##GroupProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) + { + DrawGroupGuidelinesSection(scale); + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawGroupProfileContent(profile, scale); + } + + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + private void DrawGroupGuidelinesSection(float scale) + { + DrawSection("Guidelines", scale, () => + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1f, 1f)); + + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile pictures, tags and description."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report this profile for breaking the rules."); + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)"); + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive"); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling the profile forever or terminating syncshell owner's Lightless account indefinitely."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of the profile validity from reports through staff is not up to debate and the decisions to disable the profile or your account permanent."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If the profile picture or profile description could be considered NSFW, enable the toggle in visibility settings."); + + ImGui.PopStyleVar(); + }); + } + + private void DrawGroupProfileContent(LightlessGroupProfileData profile, float scale) + { + DrawSection("Profile Preview", scale, () => DrawGroupProfileSnapshot(profile, scale)); + DrawSection("Profile Image", scale, DrawGroupProfileImageControls); + DrawSection("Profile Banner", scale, DrawGroupProfileBannerControls); + DrawSection("Profile Description", scale, DrawGroupProfileDescriptionEditor); + DrawSection("Profile Tags", scale, () => DrawGroupProfileTagsEditor(scale)); + DrawSection("Visibility", scale, DrawGroupProfileVisibilityControls); + } + + private void DrawGroupProfileSnapshot(LightlessGroupProfileData profile, float scale) + { + var bannerHeight = 140f * scale; + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##GroupProfileBannerPreview", new Vector2(-1f, bannerHeight), true)) + { + if (_bannerTextureWrap != null) + { + var childSize = ImGui.GetWindowSize(); + var padding = ImGui.GetStyle().WindowPadding; + var contentSize = new Vector2( + MathF.Max(childSize.X - padding.X * 2f, 1f), + MathF.Max(childSize.Y - padding.Y * 2f, 1f)); + + var imageSize = ImGuiHelpers.ScaledVector2(_bannerTextureWrap.Width, _bannerTextureWrap.Height); + if (imageSize.X > contentSize.X || imageSize.Y > contentSize.Y) + { + var ratio = MathF.Min(contentSize.X / MathF.Max(imageSize.X, 1f), contentSize.Y / MathF.Max(imageSize.Y, 1f)); + imageSize *= ratio; + } + + var offset = new Vector2( + MathF.Max((contentSize.X - imageSize.X) * 0.5f, 0f), + MathF.Max((contentSize.Y - imageSize.Y) * 0.5f, 0f)); + ImGui.SetCursorPos(padding + offset); + ImGui.Image(_bannerTextureWrap.Handle, imageSize); + } + else + { + ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile banner uploaded."); + } + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + + ImGui.Dummy(new Vector2(0f, 6f * scale)); + + if (_pfpTextureWrap != null) + { + var size = ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height); + var maxEdge = 160f * scale; + if (size.X > maxEdge || size.Y > maxEdge) + { + var ratio = MathF.Min(maxEdge / MathF.Max(size.X, 1f), maxEdge / MathF.Max(size.Y, 1f)); + size *= ratio; + } + + ImGui.Image(_pfpTextureWrap.Handle, size); + } + else + { + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##GroupProfileImagePlaceholder", new Vector2(160f * scale, 160f * scale), true)) + ImGui.TextColored(UIColors.Get("LightlessPurple"), "No profile picture uploaded."); + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + ImGui.SameLine(); + ImGui.BeginGroup(); + ImGui.TextColored(UIColors.Get("LightlessBlue"), _groupInfo!.GroupAliasOrGID); + ImGui.TextDisabled($"ID: {_groupInfo.Group.GID}"); + ImGui.TextDisabled($"Owner: {_groupInfo.Owner.AliasOrUID}"); + ImGui.EndGroup(); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##GroupProfileDescriptionPreview", new Vector2(-1f, 120f * scale), true)) + { + var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + if (!hasDescription) + { + ImGui.TextDisabled("Syncshell has no description set."); + } + else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(profile.Description!)) + { + UiSharedService.TextWrapped(profile.Description); + } + ImGui.PopTextWrapPos(); + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); + var savedTags = _profileTagService.ResolveTags(_profileTagIds); + if (savedTags.Count == 0) + { + ImGui.TextDisabled("-- No tags set --"); + } + else + { + bool first = true; + for (int i = 0; i < savedTags.Count; i++) + { + if (!savedTags[i].HasContent) + continue; + + if (!first) + ImGui.SameLine(0f, 6f * scale); + first = false; + + using (ImRaii.PushId($"group-snapshot-tag-{i}")) + DrawTagPreview(savedTags[i], scale, "##groupSnapshotTagPreview"); + } + if (!first) + ImGui.NewLine(); + } + } + + private void DrawGroupProfileImageControls() + { + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB."); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) + { + _fileDialogManager.OpenFileDialog("Select syncshell profile picture", ImageFileDialogFilter, (success, file) => + { + if (!success || string.IsNullOrEmpty(file)) + return; + _showProfileImageError = false; + _ = SubmitGroupProfilePicture(file); + }); + } + UiSharedService.AttachToolTip("Select an image up to 512x512 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile picture")) + { + _ = ClearGroupProfilePicture(); + } + UiSharedService.AttachToolTip("Remove the current profile picture from this syncshell."); + + if (_showProfileImageError) + { + UiSharedService.ColorTextWrapped("Image must be no larger than 512x512 pixels and under 2 MiB.", ImGuiColors.DalamudRed); + } + } + + private void DrawGroupProfileBannerControls() + { + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB."); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) + { + _fileDialogManager.OpenFileDialog("Select syncshell profile banner", ImageFileDialogFilter, (success, file) => + { + if (!success || string.IsNullOrEmpty(file)) + return; + _showBannerImageError = false; + _ = SubmitGroupProfileBanner(file); + }); + } + UiSharedService.AttachToolTip("Select an image up to 840x260 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile banner")) + { + _ = ClearGroupProfileBanner(); + } + UiSharedService.AttachToolTip("Remove the current profile banner."); + + if (_showBannerImageError) + { + UiSharedService.ColorTextWrapped("Banner must be no larger than 840x260 pixels and under 2 MiB.", ImGuiColors.DalamudRed); + } + } + + private void DrawGroupProfileDescriptionEditor() + { + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * ImGuiHelpers.GlobalScale); + var descriptionBoxSize = new Vector2(-1f, 160f * ImGuiHelpers.GlobalScale); + ImGui.InputTextMultiline("##GroupDescription", ref _descriptionText, 1500, descriptionBoxSize); + ImGui.PopStyleVar(); + + ImGui.TextDisabled($"{_descriptionText.Length}/1500 characters"); + ImGui.SameLine(); + ImGuiComponents.HelpMarker(DescriptionMacroTooltip); + + bool changed = !string.Equals(_descriptionText, _profileDescription, StringComparison.Ordinal); + if (!changed) + ImGui.BeginDisabled(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) + { + _ = SubmitGroupDescription(_descriptionText); + } + UiSharedService.AttachToolTip("Apply the text above to the syncshell profile description."); + if (!changed) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) + { + _ = SubmitGroupDescription(string.Empty); + } + UiSharedService.AttachToolTip("Remove the profile description."); + } + + private void DrawGroupProfileTagsEditor(float scale) + { + DrawTagEditor( + scale, + contextPrefix: "group", + saveTooltip: "Apply the selected tags to this syncshell profile.", + submitAction: payload => SubmitGroupTagChanges(payload), + allowReorder: true, + sortPayloadBeforeSubmit: true, + onPayloadPrepared: payload => + { + _tagEditorSelection.Clear(); + if (payload.Length > 0) + _tagEditorSelection.AddRange(payload); + }); + } + + private void DrawGroupProfileVisibilityControls() + { + bool changedNsfw = DrawCheckboxRow("Profile is NSFW", _groupIsNsfw, out var newNsfw, "Flag this profile as not safe for work."); + if (changedNsfw) + _groupIsNsfw = newNsfw; + + bool changedDisabled = DrawCheckboxRow("Disable profile for viewers", _groupIsDisabled, out var newDisabled, "Temporarily hide this profile from members."); + if (changedDisabled) + _groupIsDisabled = newDisabled; + + bool visibilityChanged = (_groupIsNsfw != _groupServerIsNsfw) || (_groupIsDisabled != _groupServerIsDisabled); + + if (!visibilityChanged) + ImGui.BeginDisabled(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Apply Visibility Changes")) + { + _ = SubmitGroupVisibilityChanges(_groupIsNsfw, _groupIsDisabled); + } + UiSharedService.AttachToolTip("Apply the visibility toggles above."); + if (!visibilityChanged) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Reset")) + { + _groupIsNsfw = _groupServerIsNsfw; + _groupIsDisabled = _groupServerIsDisabled; + } + } + + private string? GetCurrentGroupProfileImageBase64() + { + if (_queuedProfileImage is not null && _queuedProfileImage.Length > 0) + return Convert.ToBase64String(_queuedProfileImage); + + if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64ProfilePicture)) + return _groupProfileData!.Base64ProfilePicture; + + return _profileImage.Length > 0 ? Convert.ToBase64String(_profileImage) : null; + } + + private string? GetCurrentGroupBannerBase64() + { + if (_queuedBannerImage is not null && _queuedBannerImage.Length > 0) + return Convert.ToBase64String(_queuedBannerImage); + + if (!string.IsNullOrWhiteSpace(_groupProfileData?.Base64BannerPicture)) + return _groupProfileData!.Base64BannerPicture; + + return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null; + } + + private async Task SubmitGroupProfilePicture(string filePath) + { + if (_groupInfo is null) + return; + + try + { + var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); + await using var stream = new MemoryStream(fileContent); + var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); + if (!IsSupportedImageFormat(format)) + { + _showProfileImageError = true; + return; + } + + using var image = Image.Load(fileContent); + if (image.Width > 512 || image.Height > 512 || fileContent.Length > 2000 * 1024) + { + _showProfileImageError = true; + return; + } + + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: Convert.ToBase64String(fileContent), + BannerBase64: null, + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _showProfileImageError = false; + _queuedProfileImage = fileContent; + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to upload syncshell profile picture."); + } + } + + private async Task ClearGroupProfilePicture() + { + if (_groupInfo is null) + return; + + try + { + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: null, + BannerBase64: null, + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _queuedProfileImage = Array.Empty(); + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clear syncshell profile picture."); + } + } + + private async Task SubmitGroupProfileBanner(string filePath) + { + if (_groupInfo is null) + return; + + try + { + var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); + await using var stream = new MemoryStream(fileContent); + var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); + if (!IsSupportedImageFormat(format)) + { + _showBannerImageError = true; + return; + } + + using var image = Image.Load(fileContent); + if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) + { + _showBannerImageError = true; + return; + } + + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: null, + BannerBase64: Convert.ToBase64String(fileContent), + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _showBannerImageError = false; + _queuedBannerImage = fileContent; + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to upload syncshell profile banner."); + } + } + + private async Task ClearGroupProfileBanner() + { + if (_groupInfo is null) + return; + + try + { + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: null, + BannerBase64: null, + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _queuedBannerImage = Array.Empty(); + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to clear syncshell profile banner."); + } + } + + private async Task SubmitGroupDescription(string description) + { + if (_groupInfo is null) + return; + + try + { + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: description, + Tags: null, + PictureBase64: GetCurrentGroupProfileImageBase64(), + BannerBase64: GetCurrentGroupBannerBase64(), + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _profileDescription = description; + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update syncshell profile description."); + } + } + + private async Task SubmitGroupTagChanges(int[] payload) + { + if (_groupInfo is null) + return; + + try + { + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: payload, + PictureBase64: GetCurrentGroupProfileImageBase64(), + BannerBase64: GetCurrentGroupBannerBase64(), + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _profileTagIds = payload.Length == 0 ? Array.Empty() : payload.ToArray(); + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update syncshell profile tags."); + } + } + + private async Task SubmitGroupVisibilityChanges(bool isNsfw, bool isDisabled) + { + if (_groupInfo is null) + return; + + try + { + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: GetCurrentGroupProfileImageBase64(), + BannerBase64: GetCurrentGroupBannerBase64(), + IsNsfw: isNsfw, + IsDisabled: isDisabled)).ConfigureAwait(false); + + _groupServerIsNsfw = isNsfw; + _groupServerIsDisabled = isDisabled; + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to update syncshell profile visibility."); + } + } + + private void ApplyQueuedGroupProfileImage() + { + if (_queuedProfileImage is null) + return; + + _profileImage = _queuedProfileImage; + _pfpTextureWrap?.Dispose(); + _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; + _queuedProfileImage = null; + } + + private void ApplyQueuedGroupBannerImage() + { + if (_queuedBannerImage is null) + return; + + _bannerImage = _queuedBannerImage; + _bannerTextureWrap?.Dispose(); + _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; + _queuedBannerImage = null; + } + + private void SyncGroupProfileState(LightlessGroupProfileData profile, bool resetSelection) + { + if (!_profileImage.SequenceEqual(profile.ProfileImageData.Value)) + { + _profileImage = profile.ProfileImageData.Value; + _pfpTextureWrap?.Dispose(); + _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; + } + + if (!_bannerImage.SequenceEqual(profile.BannerImageData.Value)) + { + _bannerImage = profile.BannerImageData.Value; + _bannerTextureWrap?.Dispose(); + _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; + } + + if (!string.Equals(_profileDescription, profile.Description, StringComparison.Ordinal)) + { + _profileDescription = profile.Description; + _descriptionText = _profileDescription; + } + + var tags = profile.Tags ?? Array.Empty(); + if (!TagsEqual(tags, _profileTagIds)) + { + _profileTagIds = tags.Count == 0 ? Array.Empty() : tags.ToArray(); + if (resetSelection) + { + _tagEditorSelection.Clear(); + if (_profileTagIds.Length > 0) + _tagEditorSelection.AddRange(_profileTagIds); + } + } + + _groupIsNsfw = profile.IsNsfw; + _groupIsDisabled = profile.IsDisabled; + _groupServerIsNsfw = profile.IsNsfw; + _groupServerIsDisabled = profile.IsDisabled; + } + +} + diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 5dedf81..62d4bde 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -1,37 +1,93 @@ -using Dalamud.Bindings.ImGui; +using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; +using Dalamud.Interface.Components; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data; +using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.UI.Style; +using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; +using System; +using System.Collections.Generic; +using System.IO; using System.Numerics; +using System.Threading.Tasks; +using System.Linq; namespace LightlessSync.UI; -public class EditProfileUi : WindowMediatorSubscriberBase +public partial class EditProfileUi : WindowMediatorSubscriberBase { private readonly ApiController _apiController; private readonly FileDialogManager _fileDialogManager; private readonly LightlessProfileManager _lightlessProfileManager; private readonly UiSharedService _uiSharedService; - private bool _adjustedForScollBarsLocalProfile = false; - private bool _adjustedForScollBarsOnlineProfile = false; + private readonly ProfileTagService _profileTagService; + private const string LoadingProfileDescription = "Loading Profile Data from server..."; + private const string DescriptionMacroTooltip = + "Supported SeString markup:\n" + + "
- insert a line break (Enter already emits these).\n" + + "<-> - optional soft hyphen / word break.\n" + + " / - show game icons by numeric id;" + + "text or - tint text; reset with or .\n" + + " / - use UI palette colours (0 restores defaults).\n" + + " / - change outline colour.\n" + + " - toggle style flags.\n" + + " - create clickable links."; + + private static readonly HashSet SupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "png", + "jpg", + "jpeg", + "webp", + "bmp" + }; + private const string ImageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; + private readonly List _tagEditorSelection = new(); + private int[] _profileTagIds = Array.Empty(); + private readonly List _tagPreviewSegments = new(); + private enum ProfileEditorMode + { + User, + Group + } + + private ProfileEditorMode _mode = ProfileEditorMode.User; + private GroupFullInfoDto? _groupInfo; + private LightlessGroupProfileData? _groupProfileData; + private bool _groupIsNsfw; + private bool _groupIsDisabled; + private bool _groupServerIsNsfw; + private bool _groupServerIsDisabled; + private byte[]? _queuedProfileImage; + private byte[]? _queuedBannerImage; + private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + private const int MaxProfileTags = 12; + private const int AvailableTagsPerPage = 6; + private int _availableTagPage; + private UserData? _selfProfileUserData; private string _descriptionText = string.Empty; private IDalamudTextureWrap? _pfpTextureWrap; + private IDalamudTextureWrap? _bannerTextureWrap; private string _profileDescription = string.Empty; private byte[] _profileImage = []; - private bool _showFileDialogError = false; + private byte[] _bannerImage = []; + private bool _showProfileImageError = false; + private bool _showBannerImageError = false; private bool _wasOpen; private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); @@ -46,23 +102,33 @@ public class EditProfileUi : WindowMediatorSubscriberBase public EditProfileUi(ILogger logger, LightlessMediator mediator, ApiController apiController, UiSharedService uiSharedService, FileDialogManager fileDialogManager, - LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService) + LightlessProfileManager lightlessProfileManager, ProfileTagService profileTagService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Sync Edit Profile###LightlessSyncEditProfileUI", performanceCollectorService) { IsOpen = false; - this.SizeConstraints = new() + var scale = ImGuiHelpers.GlobalScale; + var editorSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); + Size = editorSize; + SizeCondition = ImGuiCond.FirstUseEver; + SizeConstraints = new() { - MinimumSize = new(850, 640), - MaximumSize = new(850, 700) + MinimumSize = editorSize, + MaximumSize = editorSize }; + Flags |= ImGuiWindowFlags.NoResize; _apiController = apiController; _uiSharedService = uiSharedService; _fileDialogManager = fileDialogManager; _lightlessProfileManager = lightlessProfileManager; + _profileTagService = profileTagService; Mediator.Subscribe(this, (_) => { _wasOpen = IsOpen; IsOpen = false; }); Mediator.Subscribe(this, (_) => IsOpen = _wasOpen); - Mediator.Subscribe(this, (_) => IsOpen = false); + Mediator.Subscribe(this, (_) => + { + IsOpen = false; + _selfProfileUserData = null; + }); Mediator.Subscribe(this, (msg) => { if (msg.UserData == null || string.Equals(msg.UserData.UID, _apiController.UID, StringComparison.Ordinal)) @@ -73,8 +139,17 @@ public class EditProfileUi : WindowMediatorSubscriberBase }); Mediator.Subscribe(this, msg => { + _selfProfileUserData = msg.Connection.User with + { + IsAdmin = msg.Connection.IsAdmin, + IsModerator = msg.Connection.IsModerator, + HasVanity = msg.Connection.HasVanity, + TextColorHex = msg.Connection.TextColorHex, + TextGlowColorHex = msg.Connection.TextGlowColorHex + }; LoadVanity(); }); + Mediator.Subscribe(this, msg => OpenGroupEditor(msg.Group)); } private void LoadVanity() @@ -89,309 +164,1111 @@ public class EditProfileUi : WindowMediatorSubscriberBase vanityInitialized = true; } + public override async void OnOpen() + { + if (_mode == ProfileEditorMode.Group) + { + if (_groupInfo is not null) + { + var scale = ImGuiHelpers.GlobalScale; + var viewport = ImGui.GetMainViewport(); + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + } + return; + } + + var user = await EnsureSelfProfileUserDataAsync().ConfigureAwait(false); + if (user is not null) + { + ProfileEditorLayoutCoordinator.Enable(user.UID); + var scale = ImGuiHelpers.GlobalScale; + var viewport = ImGui.GetMainViewport(); + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + Mediator.Publish(new OpenSelfProfilePreviewMessage(user)); + } + } + + public override void OnClose() + { + if (_mode == ProfileEditorMode.Group) + { + if (_groupInfo is not null) + { + ProfileEditorLayoutCoordinator.Disable(_groupInfo.Group.GID); + Mediator.Publish(new CloseGroupProfilePreviewMessage(_groupInfo)); + } + + ResetGroupEditorState(); + _mode = ProfileEditorMode.User; + return; + } + + if (_selfProfileUserData is not null) + { + ProfileEditorLayoutCoordinator.Disable(_selfProfileUserData.UID); + Mediator.Publish(new CloseSelfProfilePreviewMessage(_selfProfileUserData)); + } + } + + private async Task EnsureSelfProfileUserDataAsync() + { + if (_selfProfileUserData is not null) + return _selfProfileUserData; + + try + { + var connection = await _apiController.GetConnectionDtoAsync(publishConnected: false).ConfigureAwait(false); + _selfProfileUserData = connection.User with + { + IsAdmin = connection.IsAdmin, + IsModerator = connection.IsModerator, + HasVanity = connection.HasVanity, + TextColorHex = connection.TextColorHex, + TextGlowColorHex = connection.TextGlowColorHex + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to acquire connection information for profile preview."); + } + + return _selfProfileUserData; + } + protected override void DrawInternal() { + var scale = ImGuiHelpers.GlobalScale; - _uiSharedService.UnderlinedBigText("Notes and Rules for Profiles", UIColors.Get("LightlessYellow")); - ImGui.Dummy(new Vector2(5)); + if (_mode == ProfileEditorMode.Group) + { + DrawGroupEditor(scale); + return; + } - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, 1)); + var viewport = ImGui.GetMainViewport(); + var linked = _selfProfileUserData is not null && ProfileEditorLayoutCoordinator.IsActive(_selfProfileUserData.UID); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile picture and description."); - _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report your profile for breaking the rules."); - _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)"); - _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive"); - _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling your profile forever or terminating your Lightless account indefinitely."); - _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of your profile validity from reports through staff is not up to debate and the decisions to disable your profile/account permanent."); - _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If your profile picture or profile description could be considered NSFW, enable the toggle in profile settings."); + if (linked) + { + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); - ImGui.PopStyleVar(); + var desiredSize = ProfileEditorLayoutCoordinator.GetEditorSize(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); - ImGui.Dummy(new Vector2(3)); + var currentPos = ImGui.GetWindowPos(); + if (IsWindowBeingDragged()) + ProfileEditorLayoutCoordinator.UpdateAnchorFromEditor(currentPos, scale); + + var desiredPos = ProfileEditorLayoutCoordinator.GetEditorPosition(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) + ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); + } + else + { + var defaultProfilePos = viewport.WorkPos + new Vector2(50f, 70f) * scale; + var defaultEditorPos = defaultProfilePos + ProfileEditorLayoutCoordinator.GetEditorOffset(scale); + ImGui.SetWindowPos(defaultEditorPos, ImGuiCond.FirstUseEver); + } var profile = _lightlessProfileManager.GetLightlessUserProfile(new UserData(_apiController.UID)); - _logger.LogInformation("Profile fetched for drawing: {profile}", profile); - if (ImGui.BeginTabBar("##EditProfileTabs")) - { - if (ImGui.BeginTabItem("Current Profile")) + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.015f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); + + using var panelBg = ImRaii.PushColor(ImGuiCol.ChildBg, accentBg); + using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + + if (ImGui.BeginChild("##ProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) + { + DrawGuidelinesSection(scale); + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawTabInterface(profile, scale); + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + private void DrawGuidelinesSection(float scale) + { + DrawSection("Guidelines", scale, () => + { + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(1, 1)); + + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "All users that are paired and unpaused with you will be able to see your profile pictures, tags and description."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Other users have the possibility to report your profile for breaking the rules."); + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Anything as profile image that can be considered highly illegal or obscene (bestiality, anything that could be considered a sexual act with a minor (that includes Lalafells), etc.)"); + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), "AVOID: Slurs of any kind in the description that can be considered highly offensive"); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "In case of valid reports from other users this can lead to disabling your profile forever or terminating your Lightless account indefinitely."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), "Judgement of your profile validity from reports through staff is not up to debate and the decisions to disable your profile/account permanent."); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), "If your profile picture or profile description could be considered NSFW, enable the toggle in visibility settings."); + + ImGui.PopStyleVar(); + }); + } + + private void DrawTabInterface(LightlessUserProfileData profile, float scale) + { + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 4f * scale); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(8f, 4f) * scale); + + if (ImGui.BeginTabBar("##ProfileEditorTabs", ImGuiTabBarFlags.NoCloseWithMiddleMouseButton | ImGuiTabBarFlags.FittingPolicyResizeDown)) + { + if (ImGui.BeginTabItem("Profile")) { - _uiSharedService.MediumText("Current Profile (as saved on server)", UIColors.Get("LightlessPurple")); - ImGui.Dummy(new Vector2(5)); - - if (profile.IsFlagged) - { - UiSharedService.ColorTextWrapped(profile.Description, ImGuiColors.DalamudRed); - return; - } - - if (!_profileImage.SequenceEqual(profile.ImageData.Value)) - { - _profileImage = profile.ImageData.Value; - _pfpTextureWrap?.Dispose(); - _pfpTextureWrap = _uiSharedService.LoadImage(_profileImage); - } - - if (!string.Equals(_profileDescription, profile.Description, StringComparison.OrdinalIgnoreCase)) - { - _profileDescription = profile.Description; - _descriptionText = _profileDescription; - } - - if (_pfpTextureWrap != null) - { - ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height)); - } - - var spacing = ImGui.GetStyle().ItemSpacing.X; - ImGuiHelpers.ScaledRelativeSameLine(256, spacing); - using (_uiSharedService.GameFont.Push()) - { - var descriptionTextSize = ImGui.CalcTextSize(profile.Description, wrapWidth: 256f); - var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256); - if (descriptionTextSize.Y > childFrame.Y) - { - _adjustedForScollBarsOnlineProfile = true; - } - else - { - _adjustedForScollBarsOnlineProfile = false; - } - childFrame = childFrame with - { - X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0), - }; - if (ImGui.BeginChildFrame(101, childFrame)) - { - UiSharedService.TextWrapped(profile.Description); - } - ImGui.EndChildFrame(); - } - - var nsfw = profile.IsNSFW; - ImGui.BeginDisabled(); - ImGui.Checkbox("Is NSFW", ref nsfw); - ImGui.EndDisabled(); - - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + DrawProfileTabContent(profile, scale); ImGui.EndTabItem(); } - if (ImGui.BeginTabItem("Profile Settings")) + if (ImGui.BeginTabItem("Vanity")) { - _uiSharedService.MediumText("Profile Settings", UIColors.Get("LightlessPurple")); - ImGui.Dummy(new Vector2(5)); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) - { - _fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) => - { - if (!success) return; - _ = Task.Run(async () => - { - var fileContent = File.ReadAllBytes(file); - using MemoryStream ms = new(fileContent); - var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); - if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase)) - { - _showFileDialogError = true; - return; - } - using var image = Image.Load(fileContent); - - if (image.Width > 256 || image.Height > 256 || (fileContent.Length > 250 * 1024)) - { - _showFileDialogError = true; - return; - } - - _showFileDialogError = false; - await _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, Convert.ToBase64String(fileContent), BannerPictureBase64: null, Description: null, Tags: null)) - .ConfigureAwait(false); - }); - }); - } - UiSharedService.AttachToolTip("Select and upload a new profile picture"); - ImGui.SameLine(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture")) - { - _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, "", Description: null, BannerPictureBase64: null, Tags: null)); - } - UiSharedService.AttachToolTip("Clear your currently uploaded profile picture"); - if (_showFileDialogError) - { - UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed); - } - var isNsfw = profile.IsNSFW; - if (ImGui.Checkbox("Profile is NSFW", ref isNsfw)) - { - _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, isNsfw, ProfilePictureBase64: null, Description: null, BannerPictureBase64: null, Tags: null)); - } - _uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON"); - var widthTextBox = 400; - var posX = ImGui.GetCursorPosX(); - ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500"); - ImGui.SetCursorPosX(posX); - ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X); - ImGui.TextUnformatted("Preview (approximate)"); - using (_uiSharedService.GameFont.Push()) - ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200)); - - ImGui.SameLine(); - - using (_uiSharedService.GameFont.Push()) - { - var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f); - var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200); - if (descriptionTextSizeLocal.Y > childFrameLocal.Y) - { - _adjustedForScollBarsLocalProfile = true; - } - else - { - _adjustedForScollBarsLocalProfile = false; - } - childFrameLocal = childFrameLocal with - { - X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0), - }; - if (ImGui.BeginChildFrame(102, childFrameLocal)) - { - UiSharedService.TextWrapped(_descriptionText); - } - ImGui.EndChildFrame(); - } - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) - { - _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, _descriptionText, Tags: null)); - } - UiSharedService.AttachToolTip("Sets your profile description text"); - ImGui.SameLine(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) - { - _ = _apiController.UserSetProfile(new UserProfileDto(new UserData(_apiController.UID), Disabled: false, IsNSFW: null, ProfilePictureBase64: null, BannerPictureBase64: null, "", Tags: null)); - } - UiSharedService.AttachToolTip("Clears your profile description text"); - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Vanity Settings")) - { - _uiSharedService.MediumText("Supporter Vanity Settings", UIColors.Get("LightlessPurple")); - ImGui.Dummy(new Vector2(4)); - _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessPurple"), "Must be a supporter through Patreon/Ko-fi to access these settings. If you have the vanity role, you must interact with the Discord bot first."); - - var hasVanity = _apiController.HasVanity; - - if (!hasVanity) - { - UiSharedService.ColorTextWrapped("You do not currently have vanity access. Become a supporter to unlock these features.", UIColors.Get("DimRed")); - ImGui.Dummy(new Vector2(8)); - ImGui.BeginDisabled(); - } - - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - _uiSharedService.MediumText("Colored UID", UIColors.Get("LightlessPurple")); - ImGui.Dummy(new Vector2(5)); - - var font = UiBuilder.MonoFont; - var playerUID = _apiController.UID; - var playerDisplay = _apiController.DisplayName; - - var previewTextColor = textEnabled ? textColor : Vector4.One; - var previewGlowColor = glowEnabled ? glowColor : Vector4.Zero; - - var seString = SeStringUtils.BuildFormattedPlayerName(playerDisplay, previewTextColor, previewGlowColor); - - using (ImRaii.PushFont(font)) - { - var drawList = ImGui.GetWindowDrawList(); - var textSize = ImGui.CalcTextSize(seString.TextValue); - - float minWidth = 150f * ImGuiHelpers.GlobalScale; - float bgWidth = Math.Max(textSize.X + 20f, minWidth); - - float paddingY = 5f * ImGuiHelpers.GlobalScale; - - var cursor = ImGui.GetCursorScreenPos(); - - var rectMin = cursor; - var rectMax = rectMin + new Vector2(bgWidth, textSize.Y + (paddingY * 2f)); - - float boost = Luminance.ComputeHighlight(previewTextColor, previewGlowColor); - - var baseBg = new Vector4(0.15f + boost, 0.15f + boost, 0.15f + boost, 1f); - var bgColor = Luminance.BackgroundContrast(previewTextColor, previewGlowColor, baseBg, ref _currentBg); - - var borderColor = UIColors.Get("LightlessPurple"); - - drawList.AddRectFilled(rectMin, rectMax, ImGui.GetColorU32(bgColor), 6.0f); - drawList.AddRect(rectMin, rectMax, ImGui.GetColorU32(borderColor), 6.0f, ImDrawFlags.None, 1.5f); - - var textPos = new Vector2( - rectMin.X + (bgWidth - textSize.X) * 0.5f, - rectMin.Y + paddingY - ); - - SeStringUtils.RenderSeStringWithHitbox(seString, textPos, font); - - ImGui.Dummy(new Vector2(5)); - } - - const float colorPickAlign = 90f; - - _uiSharedService.DrawNoteLine("- ", UIColors.Get("LightlessPurple"), "Text Color"); - ImGui.SameLine(colorPickAlign); - ImGui.Checkbox("##toggleTextColor", ref textEnabled); - ImGui.SameLine(); - ImGui.BeginDisabled(!textEnabled); - ImGui.ColorEdit4($"##color_text", ref textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); - ImGui.EndDisabled(); - - _uiSharedService.DrawNoteLine("- ", UIColors.Get("LightlessPurple"), "Glow Color"); - ImGui.SameLine(colorPickAlign); - ImGui.Checkbox("##toggleGlowColor", ref glowEnabled); - ImGui.SameLine(); - ImGui.BeginDisabled(!glowEnabled); - ImGui.ColorEdit4($"##color_glow", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); - ImGui.EndDisabled(); - - bool changed = !Equals(_savedVanity, new VanityState(textEnabled, glowEnabled, textColor, glowColor)); - - if (!changed) - ImGui.BeginDisabled(); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Changes")) - { - string? newText = textEnabled ? UIColors.RgbaToHex(textColor) : string.Empty; - string? newGlow = glowEnabled ? UIColors.RgbaToHex(glowColor) : string.Empty; - - _ = _apiController.UserUpdateVanityColors(new UserVanityColorsDto(newText, newGlow)); - - _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); - } - - if (!changed) - ImGui.EndDisabled(); - - ImGui.Dummy(new Vector2(5)); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - - if (!hasVanity) - ImGui.EndDisabled(); - + DrawVanityTabContent(scale); ImGui.EndTabItem(); } ImGui.EndTabBar(); } + ImGui.PopStyleVar(2); + } + + private void DrawProfileTabContent(LightlessUserProfileData profile, float scale) + { + if (profile.IsFlagged) + { + DrawSection("Moderation Status", scale, () => + { + UiSharedService.ColorTextWrapped(profile.Description, ImGuiColors.DalamudRed); + }); + return; + } + + SyncProfileState(profile); + + DrawSection("Profile Preview", scale, () => DrawProfileSnapshot(profile, scale)); + DrawSection("Profile Image", scale, () => DrawProfileImageControls(profile, scale)); + DrawSection("Profile Banner", scale, () => DrawProfileBannerControls(profile, scale)); + DrawSection("Profile Description", scale, () => DrawProfileDescriptionEditor(profile, scale)); + DrawSection("Profile Tags", scale, () => DrawProfileTagsEditor(profile, scale)); + DrawSection("Visibility", scale, () => DrawProfileVisibilityControls(profile)); + } + + private void DrawProfileSnapshot(LightlessUserProfileData profile, float scale) + { + var bannerHeight = 140f * scale; + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##ProfileBannerPreview", new Vector2(-1f, bannerHeight), true)) + { + if (_bannerTextureWrap != null) + { + var childSize = ImGui.GetWindowSize(); + var padding = ImGui.GetStyle().WindowPadding; + var contentSize = new Vector2( + MathF.Max(childSize.X - padding.X * 2f, 1f), + MathF.Max(childSize.Y - padding.Y * 2f, 1f)); + + var imageSize = ImGuiHelpers.ScaledVector2(_bannerTextureWrap.Width, _bannerTextureWrap.Height); + if (imageSize.X > contentSize.X || imageSize.Y > contentSize.Y) + { + var ratio = MathF.Min(contentSize.X / MathF.Max(imageSize.X, 1f), contentSize.Y / MathF.Max(imageSize.Y, 1f)); + imageSize *= ratio; + } + + var offset = new Vector2( + MathF.Max((contentSize.X - imageSize.X) * 0.5f, 0f), + MathF.Max((contentSize.Y - imageSize.Y) * 0.5f, 0f)); + ImGui.SetCursorPos(padding + offset); + ImGui.Image(_bannerTextureWrap.Handle, imageSize); + } + else + { + ImGui.TextColored(UIColors.Get("LightlessPurple"), "No Profile Banner"); + } + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + + ImGui.Dummy(new Vector2(0f, 6f * scale)); + + if (_pfpTextureWrap != null) + { + var size = ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height); + var maxEdge = 150f * scale; + if (size.X > maxEdge || size.Y > maxEdge) + { + var ratio = MathF.Min(maxEdge / MathF.Max(size.X, 1f), maxEdge / MathF.Max(size.Y, 1f)); + size *= ratio; + } + + ImGui.Image(_pfpTextureWrap.Handle, size); + } + else + { + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##ProfileImagePlaceholder", new Vector2(150f * scale, 150f * scale), true)) + ImGui.TextColored(UIColors.Get("LightlessPurple"), "No Profile Picture"); + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + using (_uiSharedService.GameFont.Push()) + { + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##CurrentProfileDescription", new Vector2(-1f, 120f * scale), true)) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + if (string.IsNullOrWhiteSpace(profile.Description)) + { + ImGui.TextDisabled("-- No description --"); + } + else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(profile.Description!)) + { + UiSharedService.TextWrapped(profile.Description); + } + ImGui.PopTextWrapPos(); + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); + var savedTags = _profileTagService.ResolveTags(_profileTagIds); + if (savedTags.Count == 0) + { + ImGui.TextDisabled("-- No tags set --"); + } + else + { + bool first = true; + for (int i = 0; i < savedTags.Count; i++) + { + if (!savedTags[i].HasContent) + continue; + + if (!first) + ImGui.SameLine(0f, 6f * scale); + first = false; + using (ImRaii.PushId($"snapshot-tag-{i}")) + DrawTagPreview(savedTags[i], scale, "##snapshotTagPreview"); + } + if (!first) + ImGui.NewLine(); + } + } + + private void DrawProfileImageControls(LightlessUserProfileData profile, float scale) + { + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB."); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) + { + var existingBanner = GetCurrentProfileBannerBase64(profile); + _fileDialogManager.OpenFileDialog("Select new Profile picture", ImageFileDialogFilter, (success, file) => + { + if (!success) return; + _ = Task.Run(async () => + { + var fileContent = File.ReadAllBytes(file); + using MemoryStream ms = new(fileContent); + var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); + if (!IsSupportedImageFormat(format)) + { + _showProfileImageError = true; + return; + } + + using var image = Image.Load(fileContent); + if (image.Width > 512 || image.Height > 512 || fileContent.Length > 2000 * 1024) + { + _showProfileImageError = true; + return; + } + + _showProfileImageError = false; + var currentTags = GetServerTagPayload(); + _queuedProfileImage = fileContent; + await _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: Convert.ToBase64String(fileContent), + BannerPictureBase64: existingBanner, + Description: null, + Tags: currentTags)).ConfigureAwait(false); + }); + }); + } + UiSharedService.AttachToolTip("Select an image up to 512x512 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile picture")) + { + _ = _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: string.Empty, + BannerPictureBase64: GetCurrentProfileBannerBase64(profile), + Description: null, + Tags: GetServerTagPayload())); + } + UiSharedService.AttachToolTip("Remove your current profile picture."); + + if (_showProfileImageError) + { + UiSharedService.ColorTextWrapped("Your profile picture must be no larger than 512x512 pixels and under 2 MiB.", ImGuiColors.DalamudRed); + } + } + + private void DrawProfileBannerControls(LightlessUserProfileData profile, float scale) + { + _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB."); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) + { + var existingProfile = GetCurrentProfilePictureBase64(profile); + _fileDialogManager.OpenFileDialog("Select new Profile banner", ImageFileDialogFilter, (success, file) => + { + if (!success) return; + _ = Task.Run(async () => + { + var fileContent = File.ReadAllBytes(file); + using MemoryStream ms = new(fileContent); + var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); + if (!IsSupportedImageFormat(format)) + { + _showBannerImageError = true; + return; + } + + using var image = Image.Load(fileContent); + if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) + { + _showBannerImageError = true; + return; + } + + _showBannerImageError = false; + var currentTags = GetServerTagPayload(); + await _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: existingProfile, + BannerPictureBase64: Convert.ToBase64String(fileContent), + Description: null, + Tags: currentTags)).ConfigureAwait(false); + }); + }); + } + UiSharedService.AttachToolTip("Select an image up to 840x260 pixels and <= 2 MiB (PNG/JPG/JPEG/WEBP/BMP)."); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear profile banner")) + { + _ = _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + BannerPictureBase64: string.Empty, + Description: null, + Tags: GetServerTagPayload())); + } + UiSharedService.AttachToolTip("Remove your current profile banner."); + + if (_showBannerImageError) + { + UiSharedService.ColorTextWrapped("Your banner image must be no larger than 840x260 pixels and under 2 MiB.", ImGuiColors.DalamudRed); + } + } + + private void DrawProfileDescriptionEditor(LightlessUserProfileData profile, float scale) + { + ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500"); + ImGui.SameLine(); + ImGuiComponents.HelpMarker(DescriptionMacroTooltip); + using (_uiSharedService.GameFont.Push()) + { + var inputSize = new Vector2(-1f, 160f * scale); + ImGui.InputTextMultiline("##profileDescriptionInput", ref _descriptionText, 1500, inputSize); + } + + ImGui.Dummy(new Vector2(0f, 3f * scale)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), "Preview"); + using (_uiSharedService.GameFont.Push()) + { + ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); + if (ImGui.BeginChild("##profileDescriptionPreview", new Vector2(-1f, 140f * scale), true)) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + if (string.IsNullOrWhiteSpace(_descriptionText)) + { + ImGui.TextDisabled("-- Description preview --"); + } + else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(_descriptionText)) + { + UiSharedService.TextWrapped(_descriptionText); + } + ImGui.PopTextWrapPos(); + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) + { + _ = _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + BannerPictureBase64: GetCurrentProfileBannerBase64(profile), + _descriptionText, + Tags: GetServerTagPayload())); + } + UiSharedService.AttachToolTip("Apply the text above to your profile."); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) + { + _descriptionText = string.Empty; + _ = _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + BannerPictureBase64: GetCurrentProfileBannerBase64(profile), + Description: string.Empty, + Tags: GetServerTagPayload())); + } + UiSharedService.AttachToolTip("Remove the description from your profile."); + } + + private void DrawProfileTagsEditor(LightlessUserProfileData profile, float scale) + { + DrawTagEditor( + scale, + contextPrefix: "user", + saveTooltip: "Apply the selected tags to your profile.", + submitAction: payload => _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + IsNSFW: null, + ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + BannerPictureBase64: GetCurrentProfileBannerBase64(profile), + Description: null, + Tags: payload)), + allowReorder: true, + sortPayloadBeforeSubmit: false); + } + + private void DrawTagEditor( + float scale, + string contextPrefix, + string saveTooltip, + Func submitAction, + bool allowReorder, + bool sortPayloadBeforeSubmit, + Action? onPayloadPrepared = null) + { + var tagLibrary = _profileTagService.GetTagLibrary(); + if (tagLibrary.Count == 0) + { + ImGui.TextDisabled("No profile tags are available."); + return; + } + + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var selectedCount = _tagEditorSelection.Count; + ImGui.TextColored(UIColors.Get("LightlessBlue"), $"Selected Tags ({selectedCount}/{MaxProfileTags})"); + + int? tagToRemove = null; + int? moveUpRequest = null; + int? moveDownRequest = null; + + if (selectedCount == 0) + { + ImGui.TextDisabled("-- No tags selected --"); + } + else + { + var selectedFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchSame; + var selectedTableId = $"##{contextPrefix}SelectedTagsTable"; + var columnCount = allowReorder ? 3 : 2; + + if (ImGui.BeginTable(selectedTableId, columnCount, selectedFlags)) + { + ImGui.TableSetupColumn("Preview", ImGuiTableColumnFlags.WidthStretch, allowReorder ? 0.55f : 0.75f); + if (allowReorder) + ImGui.TableSetupColumn("##order", ImGuiTableColumnFlags.WidthStretch, 0.1f); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 90f); + ImGui.TableHeadersRow(); + + for (int i = 0; i < _tagEditorSelection.Count; i++) + { + var tagId = _tagEditorSelection[i]; + if (!tagLibrary.TryGetValue(tagId, out var definition) || !definition.HasContent) + continue; + + var displayName = GetTagDisplayName(definition, tagId); + var idLabel = $"ID {tagId}"; + var previewSize = ProfileTagRenderer.MeasureTag(definition, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); + var textHeight = ImGui.CalcTextSize(displayName).Y + style.ItemSpacing.Y + ImGui.CalcTextSize(idLabel).Y; + var rowHeight = MathF.Max(previewSize.Y + style.CellPadding.Y * 2f, textHeight + style.CellPadding.Y * 2f); + + ImGui.TableNextRow(ImGuiTableRowFlags.None, rowHeight); + ImGui.TableNextColumn(); + using (ImRaii.PushId($"{contextPrefix}-selected-tag-{tagId}-{i}")) + DrawCenteredTagCell(definition, scale, previewSize, rowHeight, defaultTextColorU32); + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(displayName); + ImGui.TextDisabled(idLabel); + ImGui.EndTooltip(); + } + + if (allowReorder) + { + ImGui.TableNextColumn(); + DrawReorderCell(contextPrefix, tagId, i, _tagEditorSelection.Count, rowHeight, scale, ref moveUpRequest, ref moveDownRequest); + } + + ImGui.TableNextColumn(); + DrawFullCellButton("Remove", UIColors.Get("DimRed"), ref tagToRemove, tagId, false, scale, rowHeight, $"{contextPrefix}-remove-{tagId}-{i}"); + } + + ImGui.EndTable(); + } + } + + if (allowReorder) + { + if (moveUpRequest.HasValue && moveUpRequest.Value > 0) + { + var idx = moveUpRequest.Value; + (_tagEditorSelection[idx - 1], _tagEditorSelection[idx]) = (_tagEditorSelection[idx], _tagEditorSelection[idx - 1]); + } + + if (moveDownRequest.HasValue && moveDownRequest.Value < _tagEditorSelection.Count - 1) + { + var idx = moveDownRequest.Value; + (_tagEditorSelection[idx], _tagEditorSelection[idx + 1]) = (_tagEditorSelection[idx + 1], _tagEditorSelection[idx]); + } + } + + if (tagToRemove.HasValue) + _tagEditorSelection.Remove(tagToRemove.Value); + + bool limitReached = _tagEditorSelection.Count >= MaxProfileTags; + if (limitReached) + UiSharedService.ColorTextWrapped($"You have reached the maximum of {MaxProfileTags} tags. Remove one before adding more.", UIColors.Get("DimRed")); + + ImGui.Dummy(new Vector2(0f, 6f * scale)); + ImGui.TextColored(UIColors.Get("LightlessPurple"), "Available Tags"); + + var availableIds = new List(tagLibrary.Count); + var seenDefinitions = new HashSet(); + foreach (var kvp in tagLibrary) + { + var definition = kvp.Value; + if (!definition.HasContent) + continue; + + if (!seenDefinitions.Add(definition)) + continue; + + if (_tagEditorSelection.Contains(kvp.Key)) + continue; + + availableIds.Add(kvp.Key); + } + + availableIds.Sort(); + int totalAvailable = availableIds.Count; + if (totalAvailable == 0) + { + ImGui.TextDisabled("-- No additional tags available --"); + } + else + { + int pageCount = Math.Max(1, (totalAvailable + AvailableTagsPerPage - 1) / AvailableTagsPerPage); + _availableTagPage = Math.Clamp(_availableTagPage, 0, pageCount - 1); + int start = _availableTagPage * AvailableTagsPerPage; + int end = Math.Min(totalAvailable, start + AvailableTagsPerPage); + + ImGui.SameLine(); + ImGui.TextDisabled($"Page {_availableTagPage + 1}/{pageCount}"); + ImGui.SameLine(); + ImGui.BeginDisabled(_availableTagPage == 0); + if (ImGui.SmallButton($"<##{contextPrefix}TagPagePrev")) + _availableTagPage--; + ImGui.EndDisabled(); + ImGui.SameLine(); + ImGui.BeginDisabled(_availableTagPage >= pageCount - 1); + if (ImGui.SmallButton($">##{contextPrefix}TagPageNext")) + _availableTagPage++; + ImGui.EndDisabled(); + + var availableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.Borders | ImGuiTableFlags.SizingStretchSame; + int? tagToAdd = null; + + if (ImGui.BeginTable($"##{contextPrefix}AvailableTagsTable", 2, availableFlags)) + { + ImGui.TableSetupColumn("Preview", ImGuiTableColumnFlags.WidthStretch, 0.75f); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.WidthFixed, 90f); + ImGui.TableHeadersRow(); + + for (int idx = start; idx < end; idx++) + { + var tagId = availableIds[idx]; + if (!tagLibrary.TryGetValue(tagId, out var definition) || !definition.HasContent) + continue; + + var previewSize = ProfileTagRenderer.MeasureTag(definition, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); + var rowHeight = previewSize.Y + style.CellPadding.Y * 2f; + + ImGui.TableNextRow(ImGuiTableRowFlags.None, rowHeight); + ImGui.TableNextColumn(); + using (ImRaii.PushId($"{contextPrefix}-available-tag-{tagId}")) + DrawCenteredTagCell(definition, scale, previewSize, rowHeight, defaultTextColorU32); + if (ImGui.IsItemHovered()) + { + var name = GetTagDisplayName(definition, tagId); + ImGui.BeginTooltip(); + ImGui.TextUnformatted(name); + ImGui.TextDisabled($"ID {tagId}"); + ImGui.EndTooltip(); + } + + ImGui.TableNextColumn(); + DrawFullCellButton("Add", UIColors.Get("LightlessGreen"), ref tagToAdd, tagId, limitReached, scale, rowHeight, $"{contextPrefix}-add-{tagId}"); + } + + ImGui.EndTable(); + } + + if (tagToAdd.HasValue) + { + _tagEditorSelection.Add(tagToAdd.Value); + if (_availableTagPage > 0 && (totalAvailable - 1) <= start) + _availableTagPage = Math.Max(0, _availableTagPage - 1); + } + } + + bool hasChanges = !TagsEqual(_tagEditorSelection, _profileTagIds); + ImGui.Dummy(new Vector2(0f, 6f * scale)); + if (!hasChanges) + ImGui.BeginDisabled(); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, $"Save Tags##{contextPrefix}")) + { + var payload = _tagEditorSelection.Count == 0 ? Array.Empty() : _tagEditorSelection.ToArray(); + if (sortPayloadBeforeSubmit && payload.Length > 1) + Array.Sort(payload); + onPayloadPrepared?.Invoke(payload); + _ = submitAction(payload); + } + + if (!hasChanges) + ImGui.EndDisabled(); + + UiSharedService.AttachToolTip(saveTooltip); + } + + private void DrawProfileVisibilityControls(LightlessUserProfileData profile) + { + var isNsfw = profile.IsNSFW; + if (DrawCheckboxRow("Mark profile as NSFW", isNsfw, out var newValue, "Enable when your profile could be considered NSFW.")) + { + _ = _apiController.UserSetProfile(new UserProfileDto( + new UserData(_apiController.UID), + Disabled: false, + newValue, + ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + Description: null, + BannerPictureBase64: GetCurrentProfileBannerBase64(profile), + Tags: GetServerTagPayload())); + } + + } + + private string? GetCurrentProfilePictureBase64(LightlessUserProfileData profile) + { + if (_queuedProfileImage is { Length: > 0 }) + return Convert.ToBase64String(_queuedProfileImage); + + if (!string.IsNullOrWhiteSpace(profile.Base64ProfilePicture) && !string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) + return profile.Base64ProfilePicture; + + return _profileImage.Length > 0 ? Convert.ToBase64String(_profileImage) : null; + } + + private string? GetCurrentProfileBannerBase64(LightlessUserProfileData profile) + { + if (!string.IsNullOrWhiteSpace(profile.Base64BannerPicture) && !string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) + return profile.Base64BannerPicture; + + return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null; + } + + private void DrawTagPreview(ProfileTagDefinition tag, float scale, string id) + { + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); + + ImGui.InvisibleButton(id, tagSize); + var rectMin = ImGui.GetItemRectMin(); + var drawList = ImGui.GetWindowDrawList(); + ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); + } + + private static bool IsSupportedImageFormat(IImageFormat? format) + { + if (format is null) + return false; + + foreach (var ext in format.FileExtensions) + { + if (SupportedImageExtensions.Contains(ext)) + return true; + } + + return false; + } + + private void DrawCenteredTagCell(ProfileTagDefinition tag, float scale, Vector2 tagSize, float rowHeight, uint defaultTextColorU32) + { + var style = ImGui.GetStyle(); + var cellStart = ImGui.GetCursorPos(); + var available = ImGui.GetContentRegionAvail(); + var innerHeight = MathF.Max(0f, rowHeight - style.CellPadding.Y * 2f); + var offsetX = MathF.Max(0f, (available.X - tagSize.X) * 0.5f); + var offsetY = MathF.Max(0f, innerHeight - tagSize.Y) * 0.5f; + + ImGui.SetCursorPos(new Vector2(cellStart.X + offsetX, cellStart.Y + style.CellPadding.Y + offsetY)); + ImGui.InvisibleButton("##tagPreview", tagSize); + var rectMin = ImGui.GetItemRectMin(); + var drawList = ImGui.GetWindowDrawList(); + ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); + } + + private void DrawInfoCell(string displayName, string idLabel, float rowHeight, ImGuiStylePtr style) + { + var cellStart = ImGui.GetCursorPos(); + var nameSize = ImGui.CalcTextSize(displayName); + var idSize = ImGui.CalcTextSize(idLabel); + var totalHeight = nameSize.Y + style.ItemSpacing.Y + idSize.Y; + var offsetY = MathF.Max(0f, (rowHeight - totalHeight) * 0.5f) - style.CellPadding.Y; + if (offsetY < 0f) offsetY = 0f; + + ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, cellStart.Y + offsetY)); + ImGui.TextUnformatted(displayName); + ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, ImGui.GetCursorPosY() + style.ItemSpacing.Y)); + ImGui.TextDisabled(idLabel); + } + + private void DrawReorderCell( + string contextPrefix, + int tagId, + int index, + int count, + float rowHeight, + float scale, + ref int? moveUpTarget, + ref int? moveDownTarget) + { + var style = ImGui.GetStyle(); + var cellStart = ImGui.GetCursorPos(); + var availableWidth = ImGui.GetContentRegionAvail().X; + var innerHeight = MathF.Max(0f, rowHeight - style.CellPadding.Y * 2f); + var spacing = MathF.Min(style.ItemSpacing.Y * 0.5f, innerHeight * 0.15f); + var buttonHeight = MathF.Max(1f, (innerHeight - spacing) * 0.5f); + var width = MathF.Max(1f, availableWidth); + + var upColor = UIColors.Get("LightlessBlue"); + using (ImRaii.PushId($"{contextPrefix}-order-{tagId}-{index}")) + { + ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y)); + if (ColoredButton("\u2191##tagMoveUp", upColor, new Vector2(width, buttonHeight), scale, index == 0)) + moveUpTarget = index; + + ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y + buttonHeight + spacing)); + if (ColoredButton("\u2193##tagMoveDown", upColor, new Vector2(width, buttonHeight), scale, index >= count - 1)) + moveDownTarget = index; + } + } + + private void DrawFullCellButton(string label, Vector4 baseColor, ref int? target, int tagId, bool disabled, float scale, float rowHeight, string idSuffix) + { + var style = ImGui.GetStyle(); + var cellStart = ImGui.GetCursorPos(); + var available = ImGui.GetContentRegionAvail(); + var buttonHeight = MathF.Max(1f, rowHeight - style.CellPadding.Y * 2f); + var hovered = BlendTowardsWhite(baseColor, 0.15f); + var active = BlendTowardsWhite(baseColor, 0.3f); + + ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y)); + using (ImRaii.PushId(idSuffix)) + { + if (ColoredButton(label, baseColor, new Vector2(MathF.Max(available.X, 1f), buttonHeight), scale, disabled)) + target = tagId; + } + } + + private bool ColoredButton(string label, Vector4 baseColor, Vector2 size, float scale, bool disabled) + { + var style = ImGui.GetStyle(); + var hovered = BlendTowardsWhite(baseColor, 0.15f); + var active = BlendTowardsWhite(baseColor, 0.3f); + + ImGui.PushStyleColor(ImGuiCol.Button, baseColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, active); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, style.FrameRounding); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(MathF.Max(2f, style.FramePadding.X), MathF.Max(1f, style.FramePadding.Y * 0.3f)) * scale); + + ImGui.BeginDisabled(disabled); + bool clicked = ImGui.Button(label, size); + ImGui.EndDisabled(); + + ImGui.PopStyleVar(2); + ImGui.PopStyleColor(3); + + return clicked; + } + + private static float Clamp01(float value) + => value < 0f ? 0f : value > 1f ? 1f : value; + + private static Vector4 BlendTowardsWhite(Vector4 color, float amount) + { + var result = Vector4.Lerp(color, Vector4.One, Clamp01(amount)); + result.W = color.W; + return result; + } + + private static string GetTagDisplayName(ProfileTagDefinition tag, int tagId) + { + if (!string.IsNullOrWhiteSpace(tag.Text)) + return tag.Text!; + + if (!string.IsNullOrWhiteSpace(tag.SeStringPayload)) + { + var stripped = SeStringUtils.StripMarkup(tag.SeStringPayload!); + if (!string.IsNullOrWhiteSpace(stripped)) + return stripped; + } + + return $"Tag {tagId}"; + } + + private IDalamudTextureWrap? ResolveIconWrap(uint iconId) + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + return null; + } + + private int[] GetServerTagPayload() + { + if (_profileTagIds.Length == 0) + return Array.Empty(); + + var copy = new int[_profileTagIds.Length]; + Array.Copy(_profileTagIds, copy, _profileTagIds.Length); + return copy; + } + + private static bool TagsEqual(IReadOnlyList? current, IReadOnlyList? reference) + { + if (ReferenceEquals(current, reference)) + return true; + if (current is null || reference is null) + return false; + if (current.Count != reference.Count) + return false; + + for (int i = 0; i < current.Count; i++) + { + if (current[i] != reference[i]) + return false; + } + + return true; + } + + private void DrawVanityTabContent(float scale) + { + DrawSection("Colored UID", scale, () => + { + var hasVanity = _apiController.HasVanity; + if (!hasVanity) + { + UiSharedService.ColorTextWrapped("You do not currently have vanity access. Become a supporter to unlock these features. (If you already are, interact with the bot to update)", UIColors.Get("DimRed")); + } + + var monoFont = UiBuilder.MonoFont; + using (ImRaii.PushFont(monoFont)) + { + var previewTextColor = textEnabled ? textColor : Vector4.One; + var previewGlowColor = glowEnabled ? glowColor : Vector4.Zero; + var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.DisplayName, previewTextColor, previewGlowColor); + + var drawList = ImGui.GetWindowDrawList(); + var textSize = ImGui.CalcTextSize(seString.TextValue); + float minWidth = 160f * ImGuiHelpers.GlobalScale; + float bgWidth = Math.Max(textSize.X + 20f * ImGuiHelpers.GlobalScale, minWidth); + float paddingY = 5f * ImGuiHelpers.GlobalScale; + + var cursor = ImGui.GetCursorScreenPos(); + var rectMin = cursor; + var rectMax = rectMin + new Vector2(bgWidth, textSize.Y + paddingY * 2f); + + float boost = Luminance.ComputeHighlight(previewTextColor, previewGlowColor); + + var baseBg = new Vector4(0.15f + boost, 0.15f + boost, 0.15f + boost, 1f); + var bgColor = Luminance.BackgroundContrast(previewTextColor, previewGlowColor, baseBg, ref _currentBg); + var borderColor = UIColors.Get("LightlessPurple"); + + drawList.AddRectFilled(rectMin, rectMax, ImGui.GetColorU32(bgColor), 5f); + drawList.AddRect(rectMin, rectMax, ImGui.GetColorU32(borderColor), 5f, ImDrawFlags.None, 1.2f); + + var textPos = new Vector2(rectMin.X + (bgWidth - textSize.X) * 0.5f, rectMin.Y + paddingY); + SeStringUtils.RenderSeStringWithHitbox(seString, textPos, monoFont); + + ImGui.Dummy(new Vector2(0f, 1.5f)); + } + + ImGui.TextColored(UIColors.Get("LightlessPurple"), "Colors"); + if (!hasVanity) + ImGui.BeginDisabled(); + + if (DrawCheckboxRow("Enable custom text color", textEnabled, out var newTextEnabled)) + textEnabled = newTextEnabled; + + ImGui.SameLine(); + ImGui.BeginDisabled(!textEnabled); + ImGui.ColorEdit4("Text Color##vanityTextColor", ref textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); + ImGui.EndDisabled(); + + if (DrawCheckboxRow("Enable glow color", glowEnabled, out var newGlowEnabled)) + glowEnabled = newGlowEnabled; + + ImGui.SameLine(); + ImGui.BeginDisabled(!glowEnabled); + ImGui.ColorEdit4("Glow Color##vanityGlowColor", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); + ImGui.EndDisabled(); + + bool changed = !Equals(_savedVanity, new VanityState(textEnabled, glowEnabled, textColor, glowColor)); + if (!changed) + ImGui.BeginDisabled(); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Vanity Changes")) + { + string? newText = textEnabled ? UIColors.RgbaToHex(textColor) : string.Empty; + string? newGlow = glowEnabled ? UIColors.RgbaToHex(glowColor) : string.Empty; + + _ = _apiController.UserUpdateVanityColors(new UserVanityColorsDto(newText, newGlow)); + _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); + } + + if (!changed) + ImGui.EndDisabled(); + + if (!hasVanity) + ImGui.EndDisabled(); + }); + } + + private void DrawSection(string title, float scale, Action body) + { + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * scale); + + var flags = ImGuiTreeNodeFlags.SpanFullWidth | ImGuiTreeNodeFlags.Framed | ImGuiTreeNodeFlags.AllowItemOverlap | ImGuiTreeNodeFlags.DefaultOpen; + var open = ImGui.CollapsingHeader(title, flags); + ImGui.PopStyleVar(); + + if (open) + { + ImGui.Dummy(new Vector2(0f, 3f * scale)); + body(); + ImGui.Dummy(new Vector2(0f, 2f * scale)); + } + } + + private bool DrawCheckboxRow(string label, bool currentValue, out bool newValue, string? tooltip = null) + { + + bool value = currentValue; + bool changed = UiSharedService.CheckboxWithBorder(label, ref value, UIColors.Get("LightlessPurple"), 1.5f); + if (!string.IsNullOrEmpty(tooltip)) + UiSharedService.AttachToolTip(tooltip); + + newValue = value; + return changed; + } + + private void SyncProfileState(LightlessUserProfileData profile) + { + if (string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) + return; + + var profileBytes = profile.ImageData.Value; + if (_pfpTextureWrap == null || !_profileImage.SequenceEqual(profileBytes)) + { + _profileImage = profileBytes; + _pfpTextureWrap?.Dispose(); + _pfpTextureWrap = _profileImage.Length > 0 ? _uiSharedService.LoadImage(_profileImage) : null; + _queuedProfileImage = null; + } + + var bannerBytes = profile.BannerImageData.Value; + if (_bannerTextureWrap == null || !_bannerImage.SequenceEqual(bannerBytes)) + { + _bannerImage = bannerBytes; + _bannerTextureWrap?.Dispose(); + _bannerTextureWrap = _bannerImage.Length > 0 ? _uiSharedService.LoadImage(_bannerImage) : null; + } + + if (!string.Equals(_profileDescription, profile.Description, StringComparison.Ordinal)) + { + _profileDescription = profile.Description; + _descriptionText = _profileDescription; + } + + var serverTags = profile.Tags ?? Array.Empty(); + if (!TagsEqual(serverTags, _profileTagIds)) + { + var previous = _profileTagIds; + _profileTagIds = serverTags.Count == 0 ? Array.Empty() : serverTags.ToArray(); + + if (TagsEqual(_tagEditorSelection, previous)) + { + _tagEditorSelection.Clear(); + if (_profileTagIds.Length > 0) + _tagEditorSelection.AddRange(_profileTagIds); + } + } + } + + private static bool IsWindowBeingDragged() + { + return ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) && ImGui.GetIO().MouseDown[0]; } protected override void Dispose(bool disposing) { base.Dispose(disposing); _pfpTextureWrap?.Dispose(); + _bannerTextureWrap?.Dispose(); } } \ No newline at end of file diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index 4d362a9..0d45938 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -10,13 +10,16 @@ using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Style; using LightlessSync.Utils; using System; +using System.Collections.Generic; using System.Numerics; +using System.Text; namespace LightlessSync.UI.Handlers; public class IdDisplayHandler { private readonly LightlessConfigService _lightlessConfigService; + private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly LightlessMediator _mediator; private readonly ServerConfigurationManager _serverManager; private readonly Dictionary _showIdForEntry = new(StringComparer.Ordinal); @@ -30,11 +33,16 @@ public class IdDisplayHandler private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); private float _highlightBoost; - public IdDisplayHandler(LightlessMediator mediator, ServerConfigurationManager serverManager, LightlessConfigService lightlessConfigService) + public IdDisplayHandler( + LightlessMediator mediator, + ServerConfigurationManager serverManager, + LightlessConfigService lightlessConfigService, + PlayerPerformanceConfigService playerPerformanceConfigService) { _mediator = mediator; _serverManager = serverManager; _lightlessConfigService = lightlessConfigService; + _playerPerformanceConfigService = playerPerformanceConfigService; } public void DrawGroupText(string id, GroupFullInfoDto group, float textPosX, Func editBoxWidth) @@ -48,6 +56,13 @@ public class IdDisplayHandler using (ImRaii.PushFont(UiBuilder.MonoFont, textIsUid)) ImGui.TextUnformatted(playerText); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Left click to switch between ID display and alias" + + Environment.NewLine + "Right click to edit notes for this syncshell" + + Environment.NewLine + "Middle Mouse Button to open syncshell profile in a separate window"); + } + if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { var prevState = textIsUid; @@ -73,6 +88,11 @@ public class IdDisplayHandler _editEntry = group.GID; _editIsUid = false; } + + if (ImGui.IsItemClicked(ImGuiMouseButton.Middle)) + { + _mediator.Publish(new GroupProfileOpenStandaloneMessage(group)); + } } else { @@ -97,10 +117,14 @@ public class IdDisplayHandler { ImGui.SameLine(textPosX); (bool textIsUid, string playerText) = GetPlayerText(pair); + var compactPerformanceText = BuildCompactPerformanceUsageText(pair); if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal)) { ImGui.AlignTextToFramePadding(); + var rowStart = ImGui.GetCursorScreenPos(); + var rowWidth = MathF.Max(editBoxWidth.Invoke(), 0f); + var rowRightLimit = rowStart.X + rowWidth; var font = textIsUid ? UiBuilder.MonoFont : ImGui.GetFont(); @@ -125,7 +149,6 @@ public class IdDisplayHandler ? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor) : SeStringUtils.BuildPlain(playerText); - var rowStart = ImGui.GetCursorScreenPos(); var drawList = ImGui.GetWindowDrawList(); bool useHighlight = false; float highlightPadX = 0f; @@ -200,6 +223,8 @@ public class IdDisplayHandler drawList.ChannelsMerge(); } + var nameRectMin = ImGui.GetItemRectMin(); + var nameRectMax = ImGui.GetItemRectMax(); if (ImGui.IsItemHovered()) { if (!string.Equals(_lastMouseOverUid, id)) @@ -261,12 +286,43 @@ public class IdDisplayHandler { _mediator.Publish(new ProfileOpenStandaloneMessage(pair)); } + + if (!string.IsNullOrEmpty(compactPerformanceText)) + { + ImGui.SameLine(); + + const float compactFontScale = 0.85f; + ImGui.SetWindowFontScale(compactFontScale); + var compactHeight = ImGui.GetTextLineHeight(); + var nameHeight = nameRectMax.Y - nameRectMin.Y; + var targetPos = ImGui.GetCursorScreenPos(); + var availableWidth = MathF.Max(rowRightLimit - targetPos.X, 0f); + var centeredY = nameRectMin.Y + MathF.Max((nameHeight - compactHeight) * 0.5f, 0f); + float verticalOffset = 1f * ImGuiHelpers.GlobalScale; + centeredY += verticalOffset; + ImGui.SetCursorScreenPos(new Vector2(targetPos.X, centeredY)); + + var performanceText = string.Empty; + var wasTruncated = false; + if (availableWidth > 0f) + { + performanceText = TruncateTextToWidth(compactPerformanceText, availableWidth, out wasTruncated); + } + + ImGui.TextDisabled(performanceText); + ImGui.SetWindowFontScale(1f); + + if (wasTruncated && ImGui.IsItemHovered()) + { + ImGui.SetTooltip(compactPerformanceText); + } + } } else { ImGui.AlignTextToFramePadding(); - ImGui.SetNextItemWidth(editBoxWidth.Invoke()); + ImGui.SetNextItemWidth(MathF.Max(editBoxWidth.Invoke(), 0f)); if (ImGui.InputTextWithHint("##" + pair.UserData.UID, "Nick/Notes", ref _editComment, 255, ImGuiInputTextFlags.EnterReturnsTrue)) { _serverManager.SetNoteForUid(pair.UserData.UID, _editComment); @@ -346,6 +402,57 @@ public class IdDisplayHandler return (textIsUid, playerText!); } + private string? BuildCompactPerformanceUsageText(Pair pair) + { + var config = _playerPerformanceConfigService.Current; + if (!config.ShowPerformanceIndicator || !config.ShowPerformanceUsageNextToName) + { + return null; + } + + var vramBytes = pair.LastAppliedApproximateEffectiveVRAMBytes >= 0 + ? pair.LastAppliedApproximateEffectiveVRAMBytes + : pair.LastAppliedApproximateVRAMBytes; + var triangleCount = pair.LastAppliedDataTris; + if (vramBytes < 0 && triangleCount < 0) + { + return null; + } + + var segments = new List(2); + if (vramBytes >= 0) + { + segments.Add(UiSharedService.ByteToString(vramBytes)); + } + + if (triangleCount >= 0) + { + segments.Add(FormatTriangleCount(triangleCount)); + } + + return segments.Count == 0 ? null : string.Join(" / ", segments); + } + + private static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return string.Empty; + } + + if (triangleCount >= 1_000_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris"); + } + + if (triangleCount >= 1_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris"); + } + + return $"{triangleCount} tris"; + } + internal void Clear() { _editEntry = string.Empty; @@ -370,4 +477,52 @@ public class IdDisplayHandler return showidInsteadOfName; } -} \ No newline at end of file + + private static string TruncateTextToWidth(string text, float maxWidth, out bool wasTruncated) + { + wasTruncated = false; + if (string.IsNullOrEmpty(text) || maxWidth <= 0f) + { + return string.Empty; + } + + var fullWidth = ImGui.CalcTextSize(text).X; + if (fullWidth <= maxWidth) + { + return text; + } + + wasTruncated = true; + + const string Ellipsis = "..."; + var ellipsisWidth = ImGui.CalcTextSize(Ellipsis).X; + if (ellipsisWidth >= maxWidth) + { + return Ellipsis; + } + + var builder = new StringBuilder(text.Length); + var remainingWidth = maxWidth - ellipsisWidth; + + foreach (var rune in text.EnumerateRunes()) + { + var runeText = rune.ToString(); + var runeWidth = ImGui.CalcTextSize(runeText).X; + if (runeWidth > remainingWidth) + { + break; + } + + builder.Append(runeText); + remainingWidth -= runeWidth; + } + + if (builder.Length == 0) + { + return Ellipsis; + } + + builder.Append(Ellipsis); + return builder.ToString(); + } +} diff --git a/LightlessSync/UI/Models/PairDisplayEntry.cs b/LightlessSync/UI/Models/PairDisplayEntry.cs new file mode 100644 index 0000000..e89e7fe --- /dev/null +++ b/LightlessSync/UI/Models/PairDisplayEntry.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Pairs; + +namespace LightlessSync.UI.Models; + +public sealed record PairDisplayEntry( + PairUniqueIdentifier Ident, + PairConnection Connection, + IReadOnlyList Groups, + IPairHandlerAdapter? Handler) +{ + public UserData User => Connection.User; + public bool IsOnline => Connection.IsOnline; + public bool IsVisible => Handler?.IsVisible ?? false; + public bool IsDirectlyPaired => Connection.IsDirectlyPaired; + public bool IsOneSided => Connection.IsOneSided; + public bool HasAnyConnection => Connection.HasAnyConnection; + public string? IdentString => Connection.Ident; + public UserPermissions SelfPermissions => Connection.SelfToOtherPermissions; + public UserPermissions OtherPermissions => Connection.OtherToSelfPermissions; + public IndividualPairStatus? PairStatus => Connection.IndividualPairStatus; +} diff --git a/LightlessSync/UI/Models/PairUiEntry.cs b/LightlessSync/UI/Models/PairUiEntry.cs new file mode 100644 index 0000000..c25b6fd --- /dev/null +++ b/LightlessSync/UI/Models/PairUiEntry.cs @@ -0,0 +1,30 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Pairs; + +namespace LightlessSync.UI.Models; + +public sealed record PairUiEntry( + PairDisplayEntry DisplayEntry, + string AliasOrUid, + string DisplayName, + string Note, + bool IsVisible, + bool IsOnline, + bool IsDirectlyPaired, + bool IsOneSided, + bool HasAnyConnection, + bool IsPaused, + UserPermissions SelfPermissions, + UserPermissions OtherPermissions, + IndividualPairStatus? PairStatus, + long LastAppliedDataBytes, + long LastAppliedDataTris, + long LastAppliedApproximateVramBytes, + long LastAppliedApproximateEffectiveVramBytes, + IPairHandlerAdapter? Handler) +{ + public PairUniqueIdentifier Ident => DisplayEntry.Ident; + public IReadOnlyList Groups => DisplayEntry.Groups; +} diff --git a/LightlessSync/UI/Models/PairUiSnapshot.cs b/LightlessSync/UI/Models/PairUiSnapshot.cs new file mode 100644 index 0000000..c9226b0 --- /dev/null +++ b/LightlessSync/UI/Models/PairUiSnapshot.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Pairs; + +namespace LightlessSync.UI.Models; + +public sealed record PairUiSnapshot( + IReadOnlyDictionary PairsByUid, + IReadOnlyList DirectPairs, + IReadOnlyDictionary> GroupPairs, + IReadOnlyDictionary> PairsWithGroups, + IReadOnlyDictionary GroupsByGid, + IReadOnlyCollection Groups) +{ + public static PairUiSnapshot Empty { get; } = new( + new ReadOnlyDictionary(new Dictionary()), + Array.Empty(), + new ReadOnlyDictionary>(new Dictionary>()), + new ReadOnlyDictionary>(new Dictionary>()), + new ReadOnlyDictionary(new Dictionary()), + Array.Empty()); +} diff --git a/LightlessSync/UI/Models/VisiblePairSortMode.cs b/LightlessSync/UI/Models/VisiblePairSortMode.cs new file mode 100644 index 0000000..fcb1d65 --- /dev/null +++ b/LightlessSync/UI/Models/VisiblePairSortMode.cs @@ -0,0 +1,11 @@ +namespace LightlessSync.UI.Models; + +public enum VisiblePairSortMode +{ + Default = 0, + Alphabetical = 1, + VramUsage = 2, + EffectiveVramUsage = 3, + TriangleCount = 4, + PreferredDirectPairs = 5, +} diff --git a/LightlessSync/UI/PopoutProfileUi.cs b/LightlessSync/UI/PopoutProfileUi.cs index 1737214..baa41a2 100644 --- a/LightlessSync/UI/PopoutProfileUi.cs +++ b/LightlessSync/UI/PopoutProfileUi.cs @@ -4,10 +4,11 @@ using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using LightlessSync.API.Data.Extensions; using LightlessSync.LightlessConfiguration; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Services; +using LightlessSync.PlayerData.Pairs; using Microsoft.Extensions.Logging; using System.Numerics; @@ -16,7 +17,7 @@ namespace LightlessSync.UI; public class PopoutProfileUi : WindowMediatorSubscriberBase { private readonly LightlessProfileManager _lightlessProfileManager; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly ServerConfigurationManager _serverManager; private readonly UiSharedService _uiSharedService; private Vector2 _lastMainPos = Vector2.Zero; @@ -29,12 +30,12 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase public PopoutProfileUi(ILogger logger, LightlessMediator mediator, UiSharedService uiBuilder, ServerConfigurationManager serverManager, LightlessConfigService lightlessConfigService, - LightlessProfileManager lightlessProfileManager, PairManager pairManager, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "###LightlessSyncPopoutProfileUI", performanceCollectorService) + LightlessProfileManager lightlessProfileManager, PairUiService pairUiService, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "###LightlessSyncPopoutProfileUI", performanceCollectorService) { _uiSharedService = uiBuilder; _serverManager = serverManager; _lightlessProfileManager = lightlessProfileManager; - _pairManager = pairManager; + _pairUiService = pairUiService; Flags = ImGuiWindowFlags.NoDecoration; Mediator.Subscribe(this, (msg) => @@ -143,13 +144,17 @@ public class PopoutProfileUi : WindowMediatorSubscriberBase UiSharedService.ColorText("They: paused", UIColors.Get("LightlessYellow")); } } + var snapshot = _pairUiService.GetSnapshot(); + if (_pair.UserPair.Groups.Any()) { ImGui.TextUnformatted("Paired through Syncshells:"); foreach (var group in _pair.UserPair.Groups) { var groupNote = _serverManager.GetNoteForGid(group); - var groupName = _pairManager.GroupPairs.First(f => string.Equals(f.Key.GID, group, StringComparison.Ordinal)).Key.GroupAliasOrGID; + var groupName = snapshot.GroupsByGid.TryGetValue(group, out var groupInfo) + ? groupInfo.GroupAliasOrGID + : group; var groupString = string.IsNullOrEmpty(groupNote) ? groupName : $"{groupNote} ({groupName})"; ImGui.TextUnformatted("- " + groupString); } diff --git a/LightlessSync/UI/ProfileEditorLayoutCoordinator.cs b/LightlessSync/UI/ProfileEditorLayoutCoordinator.cs new file mode 100644 index 0000000..9a980fe --- /dev/null +++ b/LightlessSync/UI/ProfileEditorLayoutCoordinator.cs @@ -0,0 +1,84 @@ +using System; +using System.Numerics; +using System.Threading; + +namespace LightlessSync.UI; + +internal static class ProfileEditorLayoutCoordinator +{ + private static readonly Lock Gate = new(); + private static string? _activeUid; + private static Vector2? _anchor; + + private const float ProfileWidth = 840f; + private const float ProfileHeight = 525f; + private const float EditorWidth = 380f; + private const float Spacing = 0f; + private static readonly Vector2 DefaultOffset = new(50f, 70f); + + public static void Enable(string uid) + { + using var _ = Gate.EnterScope(); + if (!string.Equals(_activeUid, uid, StringComparison.Ordinal)) + _anchor = null; + _activeUid = uid; + } + + public static void Disable(string uid) + { + using var _ = Gate.EnterScope(); + if (string.Equals(_activeUid, uid, StringComparison.Ordinal)) + { + _activeUid = null; + _anchor = null; + } + } + + public static bool IsActive(string uid) + { + using var _ = Gate.EnterScope(); + return string.Equals(_activeUid, uid, StringComparison.Ordinal); + } + + public static Vector2 GetProfileSize(float scale) => new(ProfileWidth * scale, ProfileHeight * scale); + public static Vector2 GetEditorSize(float scale) => new(EditorWidth * scale, ProfileHeight * scale); + + public static Vector2 GetEditorOffset(float scale) => new((ProfileWidth + Spacing) * scale, 0f); + + public static Vector2 EnsureAnchor(Vector2 viewportOrigin, float scale) + { + using var _ = Gate.EnterScope(); + if (_anchor is null) + _anchor = viewportOrigin + DefaultOffset * scale; + return _anchor.Value; + } + + public static void UpdateAnchorFromProfile(Vector2 profilePosition) + { + using var _ = Gate.EnterScope(); + _anchor = profilePosition; + } + + public static void UpdateAnchorFromEditor(Vector2 editorPosition, float scale) + { + using var _ = Gate.EnterScope(); + _anchor = editorPosition - GetEditorOffset(scale); + } + + public static Vector2 GetProfilePosition(float scale) + { + using var _ = Gate.EnterScope(); + return _anchor ?? Vector2.Zero; + } + + public static Vector2 GetEditorPosition(float scale) + { + using var _ = Gate.EnterScope(); + return (_anchor ?? Vector2.Zero) + GetEditorOffset(scale); + } + + public static bool NearlyEquals(Vector2 current, Vector2 target, float epsilon = 0.5f) + { + return MathF.Abs(current.X - target.X) <= epsilon && MathF.Abs(current.Y - target.Y) <= epsilon; + } +} diff --git a/LightlessSync/UI/ProfileTags.cs b/LightlessSync/UI/ProfileTags.cs index 9fe4d6c..885eb7a 100644 --- a/LightlessSync/UI/ProfileTags.cs +++ b/LightlessSync/UI/ProfileTags.cs @@ -4,9 +4,30 @@ { SFW = 0, NSFW = 1, + RP = 2, ERP = 3, - Venues = 4, - Gpose = 5 + No_RP = 4, + No_ERP = 5, + + Venues = 6, + Gpose = 7, + + Limsa = 8, + Gridania = 9, + Ul_dah = 10, + + WUT = 11, + + PVP = 1001, + Ultimate = 1002, + Raids = 1003, + Roulette = 1004, + Crafting = 1005, + Casual = 1006, + Hardcore = 1007, + Glamour = 1008, + Mentor = 1009, + } } \ No newline at end of file diff --git a/LightlessSync/UI/Services/PairUiService.cs b/LightlessSync/UI/Services/PairUiService.cs new file mode 100644 index 0000000..5d38aec --- /dev/null +++ b/LightlessSync/UI/Services/PairUiService.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Factories; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Models; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.UI.Services; + +public sealed class PairUiService : DisposableMediatorSubscriberBase +{ + private readonly PairLedger _pairLedger; + private readonly PairFactory _pairFactory; + private readonly PairManager _pairManager; + + private readonly object _snapshotGate = new(); + private PairUiSnapshot _snapshot = PairUiSnapshot.Empty; + private Pair? _lastAddedPair; + private bool _needsRefresh = true; + + public PairUiService( + ILogger logger, + LightlessMediator mediator, + PairLedger pairLedger, + PairFactory pairFactory, + PairManager pairManager) : base(logger, mediator) + { + _pairLedger = pairLedger; + _pairFactory = pairFactory; + _pairManager = pairManager; + + Mediator.Subscribe(this, _ => MarkDirty()); + Mediator.Subscribe(this, _ => MarkDirty()); + Mediator.Subscribe(this, _ => MarkDirty()); + + EnsureSnapshot(); + } + + public PairUiSnapshot GetSnapshot() + { + EnsureSnapshot(); + lock (_snapshotGate) + { + return _snapshot; + } + } + + public Pair? GetLastAddedPair() + { + EnsureSnapshot(); + lock (_snapshotGate) + { + return _lastAddedPair; + } + } + + public void ClearLastAddedPair() + { + _pairManager.ClearLastAddedUser(); + lock (_snapshotGate) + { + _lastAddedPair = null; + } + } + + private void MarkDirty() + { + lock (_snapshotGate) + { + _needsRefresh = true; + } + } + + private void EnsureSnapshot() + { + bool shouldBuild; + lock (_snapshotGate) + { + shouldBuild = _needsRefresh; + if (shouldBuild) + { + _needsRefresh = false; + } + } + + if (!shouldBuild) + { + return; + } + + PairUiSnapshot snapshot; + Pair? lastAddedPair; + try + { + (snapshot, lastAddedPair) = BuildSnapshot(); + } + catch + { + lock (_snapshotGate) + { + _needsRefresh = true; + } + + throw; + } + + lock (_snapshotGate) + { + _snapshot = snapshot; + _lastAddedPair = lastAddedPair; + } + + Mediator.Publish(new PairUiUpdatedMessage(snapshot)); + } + + private (PairUiSnapshot Snapshot, Pair? LastAddedPair) BuildSnapshot() + { + var entries = _pairLedger.GetAllEntries(); + var pairByUid = new Dictionary(StringComparer.Ordinal); + + var directPairsList = new List(); + var groupPairsTemp = new Dictionary>(); + var pairsWithGroupsTemp = new Dictionary>(); + + foreach (var entry in entries) + { + var pair = _pairFactory.Create(entry); + if (pair is null) + { + continue; + } + + pairByUid[entry.Ident.UserId] = pair; + + if (entry.IsDirectlyPaired) + { + directPairsList.Add(pair); + } + + var uniqueGroups = new HashSet(StringComparer.Ordinal); + var groupList = new List(); + foreach (var group in entry.Groups) + { + if (!uniqueGroups.Add(group.Group.GID)) + { + continue; + } + + if (!groupPairsTemp.TryGetValue(group, out var members)) + { + members = new List(); + groupPairsTemp[group] = members; + } + + members.Add(pair); + groupList.Add(group); + } + + pairsWithGroupsTemp[pair] = groupList; + } + + var allGroupsList = _pairLedger.GetAllSyncshells() + .Values + .Select(s => s.GroupFullInfo) + .ToList(); + + foreach (var group in allGroupsList) + { + if (!groupPairsTemp.ContainsKey(group)) + { + groupPairsTemp[group] = new List(); + } + } + + var directPairs = new ReadOnlyCollection(directPairsList); + + var groupPairsFinal = new Dictionary>(); + foreach (var (group, members) in groupPairsTemp) + { + groupPairsFinal[group] = new ReadOnlyCollection(members); + } + + var pairsWithGroupsFinal = new Dictionary>(); + foreach (var (pair, groups) in pairsWithGroupsTemp) + { + pairsWithGroupsFinal[pair] = new ReadOnlyCollection(groups); + } + + var groupsReadOnly = new ReadOnlyCollection(allGroupsList); + var pairsByUidReadOnly = new ReadOnlyDictionary(pairByUid); + var groupsByGidReadOnly = new ReadOnlyDictionary(allGroupsList.ToDictionary(g => g.Group.GID, g => g, StringComparer.Ordinal)); + + Pair? lastAddedPair = null; + var lastAdded = _pairManager.GetLastAddedUser(); + if (lastAdded is not null) + { + if (!pairByUid.TryGetValue(lastAdded.User.UID, out lastAddedPair)) + { + var groups = lastAdded.Groups.Keys + .Select(gid => + { + var result = _pairManager.GetGroup(gid); + return result.Success ? result.Value.GroupFullInfo : null; + }) + .Where(g => g is not null) + .Cast() + .ToList(); + + var entry = new PairDisplayEntry(new PairUniqueIdentifier(lastAdded.User.UID), lastAdded, groups, null); + lastAddedPair = _pairFactory.Create(entry); + } + } + + var snapshot = new PairUiSnapshot( + pairsByUidReadOnly, + directPairs, + new ReadOnlyDictionary>(groupPairsFinal), + new ReadOnlyDictionary>(pairsWithGroupsFinal), + groupsByGidReadOnly, + groupsReadOnly); + + return (snapshot, lastAddedPair); + } +} diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 7cdeb38..cda8ac3 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1,5 +1,6 @@ using Dalamud.Bindings.ImGui; using Dalamud.Game.Text; +using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; @@ -16,8 +17,10 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.UtilsEnum.Enum; @@ -25,10 +28,12 @@ using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR.Utils; +using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; @@ -37,6 +42,9 @@ using System.Net.Http.Json; using System.Numerics; using System.Text; using System.Text.Json; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FfxivCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; +using FfxivCharacterBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; namespace LightlessSync.UI; @@ -54,7 +62,8 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly FileUploadManager _fileTransferManager; private readonly FileTransferOrchestrator _fileTransferOrchestrator; private readonly IpcManager _ipcManager; - private readonly PairManager _pairManager; + private readonly ActorObjectService _actorObjectService; + private readonly PairUiService _pairUiService; private readonly PerformanceCollectorService _performanceCollector; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PairProcessingLimiter _pairProcessingLimiter; @@ -94,7 +103,7 @@ public class SettingsUi : WindowMediatorSubscriberBase public SettingsUi(ILogger logger, UiSharedService uiShared, LightlessConfigService configService, UiThemeConfigService themeConfigService, - PairManager pairManager, + PairUiService pairUiService, ServerConfigurationManager serverConfigurationManager, PlayerPerformanceConfigService playerPerformanceConfigService, PairProcessingLimiter pairProcessingLimiter, @@ -106,12 +115,13 @@ public class SettingsUi : WindowMediatorSubscriberBase IpcManager ipcManager, CacheMonitor cacheMonitor, DalamudUtilService dalamudUtilService, HttpClient httpClient, NameplateService nameplateService, - NameplateHandler nameplateHandler) : base(logger, mediator, "Lightless Sync Settings", + NameplateHandler nameplateHandler, + ActorObjectService actorObjectService) : base(logger, mediator, "Lightless Sync Settings", performanceCollector) { _configService = configService; _themeConfigService = themeConfigService; - _pairManager = pairManager; + _pairUiService = pairUiService; _serverConfigurationManager = serverConfigurationManager; _playerPerformanceConfigService = playerPerformanceConfigService; _pairProcessingLimiter = pairProcessingLimiter; @@ -128,13 +138,15 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared = uiShared; _nameplateService = nameplateService; _nameplateHandler = nameplateHandler; + _actorObjectService = actorObjectService; AllowClickthrough = false; AllowPinning = true; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); SizeConstraints = new WindowSizeConstraints() { - MinimumSize = new Vector2(800, 400), MaximumSize = new Vector2(800, 2000), + MinimumSize = new Vector2(850f, 400f), + MaximumSize = new Vector2(850f, 2000f), }; TitleBarButtons = new() @@ -449,6 +461,74 @@ public class SettingsUi : WindowMediatorSubscriberBase } } + private void DrawTextureDownscaleCounters() + { + HashSet trackedPairs = new(); + + var snapshot = _pairUiService.GetSnapshot(); + + foreach (var pair in snapshot.DirectPairs) + { + trackedPairs.Add(pair); + } + + foreach (var group in snapshot.GroupPairs.Values) + { + foreach (var pair in group) + { + trackedPairs.Add(pair); + } + } + + long totalOriginalBytes = 0; + long totalEffectiveBytes = 0; + var hasData = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + continue; + + var original = pair.LastAppliedApproximateVRAMBytes; + var effective = pair.LastAppliedApproximateEffectiveVRAMBytes; + + if (original >= 0) + { + hasData = true; + totalOriginalBytes += original; + } + + if (effective >= 0) + { + hasData = true; + totalEffectiveBytes += effective; + } + } + + if (!hasData) + { + ImGui.TextDisabled("VRAM usage has not been calculated yet."); + return; + } + + var savedBytes = Math.Max(0L, totalOriginalBytes - totalEffectiveBytes); + var originalText = UiSharedService.ByteToString(totalOriginalBytes, addSuffix: true); + var effectiveText = UiSharedService.ByteToString(totalEffectiveBytes, addSuffix: true); + var savedText = UiSharedService.ByteToString(savedBytes, addSuffix: true); + + ImGui.TextUnformatted($"Total VRAM usage (original): {originalText}"); + ImGui.TextUnformatted($"Total VRAM usage (effective): {effectiveText}"); + + if (savedBytes > 0) + { + UiSharedService.ColorText($"VRAM saved by downscaling: {savedText}", UIColors.Get("LightlessGreen")); + } + else + { + ImGui.TextUnformatted($"VRAM saved by downscaling: {savedText}"); + } + } + private void DrawThemeVectorRow(MainStyle.StyleVector2Option option) { ImGui.TableNextRow(); @@ -1383,6 +1463,22 @@ public class SettingsUi : WindowMediatorSubscriberBase _logger.LogWarning(ex, $"Could not delete file {file} because it is in use."); } } + + foreach (var directory in Directory.GetDirectories(_configService.Current.CacheFolder)) + { + try + { + Directory.Delete(directory, recursive: true); + } + catch (IOException ex) + { + _logger.LogWarning(ex, "Could not delete directory {Directory} because it is in use.", directory); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Could not delete directory {Directory} due to access restrictions.", directory); + } + } }); } @@ -1422,8 +1518,9 @@ public class SettingsUi : WindowMediatorSubscriberBase { if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard")) { - ImGui.SetClipboardText(UiSharedService.GetNotes(_pairManager.DirectPairs - .UnionBy(_pairManager.GroupPairs.SelectMany(p => p.Value), p => p.UserData, + var snapshot = _pairUiService.GetSnapshot(); + ImGui.SetClipboardText(UiSharedService.GetNotes(snapshot.DirectPairs + .UnionBy(snapshot.GroupPairs.SelectMany(p => p.Value), p => p.UserData, UserDataComparer.Instance).ToList())); } @@ -2388,6 +2485,22 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( "Will show a performance indicator when players exceed defined thresholds in Lightless UI." + Environment.NewLine + "Will use warning thresholds."); + + using (ImRaii.Disabled(!showPerformanceIndicator)) + { + using var indent = ImRaii.PushIndent(); + bool showCompactStats = _playerPerformanceConfigService.Current.ShowPerformanceUsageNextToName; + if (ImGui.Checkbox("Show performance stats next to alias", ref showCompactStats)) + { + _playerPerformanceConfigService.Current.ShowPerformanceUsageNextToName = showCompactStats; + _playerPerformanceConfigService.Save(); + } + + _uiShared.DrawHelpText( + "Adds a text with approx. VRAM usage and triangle count to the right of pairs alias." + + Environment.NewLine + "Requires performance indicator to be enabled."); + } + bool warnOnExceedingThresholds = _playerPerformanceConfigService.Current.WarnOnExceedingThresholds; if (ImGui.Checkbox("Warn on loading in players exceeding performance thresholds", ref warnOnExceedingThresholds)) @@ -2552,6 +2665,102 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TreePop(); } + ImGui.Separator(); + + if (_uiShared.MediumTreeNode("Texture Optimization", UIColors.Get("LightlessYellow"))) + { + _uiShared.MediumText("Warning", UIColors.Get("DimRed")); + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "), + new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); + + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("This feature is encouraged to help "), + new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" and for use in "), + new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Runtime downscaling "), + new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); + + _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + + var textureConfig = _playerPerformanceConfigService.Current; + var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim; + if (ImGui.Checkbox("Trim mip levels for textures", ref trimNonIndex)) + { + textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When enabled, Lightless will remove high-resolution mip levels from textures (not index) that exceed the size limit and are not compressed with any kind compression."); + + var downscaleIndex = textureConfig.EnableIndexTextureDownscale; + if (ImGui.Checkbox("Downscale index textures above limit", ref downscaleIndex)) + { + textureConfig.EnableIndexTextureDownscale = downscaleIndex; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit."); + + var dimensionOptions = new[] { 512, 1024, 2048, 4096 }; + var optionLabels = dimensionOptions.Select(static value => value.ToString()).ToArray(); + var currentDimension = textureConfig.TextureDownscaleMaxDimension; + var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension); + if (selectedIndex < 0) + { + selectedIndex = Array.IndexOf(dimensionOptions, 2048); + } + + ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale); + if (ImGui.Combo("Maximum texture dimension", ref selectedIndex, optionLabels, optionLabels.Length)) + { + textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex]; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText($"Textures above this size will be reduced until their largest dimension is at or below the limit. Block-compressed textures are skipped when \"Only downscale uncompressed\" is enabled.{UiSharedService.TooltipSeparator}Default: 2048"); + + var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles; + if (ImGui.Checkbox("Keep original texture files", ref keepOriginalTextures)) + { + textureConfig.KeepOriginalTextureFiles = keepOriginalTextures; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("When disabled, Lightless removes the original texture after a downscaled copy is created."); + ImGui.SameLine(); + _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow"))); + + if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale) + { + UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed")); + } + + ImGui.Dummy(new Vector2(5)); + + _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f); + var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures; + if (ImGui.Checkbox("Only downscale uncompressed textures", ref onlyUncompressed)) + { + textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed; + _playerPerformanceConfigService.Save(); + } + _uiShared.DrawHelpText("If disabled, compressed textures will be targeted for downscaling too."); + _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f); + + ImGui.Dummy(new Vector2(5)); + + DrawTextureDownscaleCounters(); + + ImGui.Dummy(new Vector2(5)); + + _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + ImGui.TreePop(); + } + ImGui.Separator(); ImGui.Dummy(new Vector2(10)); @@ -3511,7 +3720,7 @@ public class SettingsUi : WindowMediatorSubscriberBase // Lightless notification locations var lightlessLocations = GetLightlessNotificationLocations(); var downloadLocations = GetDownloadNotificationLocations(); - + if (ImGui.BeginTable("##NotificationLocationTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) { ImGui.TableSetupColumn("Notification Type", ImGuiTableColumnFlags.WidthFixed, 200f * ImGuiHelpers.GlobalScale); @@ -3674,7 +3883,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTable(); } - + ImGuiHelpers.ScaledDummy(5); if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Clear All Notifications")) { @@ -3792,7 +4001,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); ImGui.TextUnformatted("Size & Layout"); - + float notifWidth = _configService.Current.NotificationWidth; if (ImGui.SliderFloat("Notification Width", ref notifWidth, 250f, 600f, "%.0f")) { @@ -3825,7 +4034,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); ImGui.TextUnformatted("Position"); - + var currentCorner = _configService.Current.NotificationCorner; if (ImGui.BeginCombo("Notification Position", GetNotificationCornerLabel(currentCorner))) { @@ -3843,7 +4052,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndCombo(); } _uiShared.DrawHelpText("Choose which corner of the screen notifications appear in."); - + int offsetY = _configService.Current.NotificationOffsetY; if (ImGui.SliderInt("Vertical Offset", ref offsetY, -2500, 2500)) { @@ -4136,7 +4345,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Separator(); // Location descriptions removed - information is now inline with each setting - + } } @@ -4256,7 +4465,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TableSetColumnIndex(2); var availableWidth = ImGui.GetContentRegionAvail().X; var buttonWidth = (availableWidth - ImGui.GetStyle().ItemSpacing.X * 2) / 3; - + // Play button using var playId = ImRaii.PushId($"Play_{typeIndex}"); using (ImRaii.Disabled(isDisabled)) @@ -4277,7 +4486,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } } UiSharedService.AttachToolTip("Test this sound"); - + // Disable toggle button ImGui.SameLine(); using var disableId = ImRaii.PushId($"Disable_{typeIndex}"); @@ -4285,11 +4494,11 @@ public class SettingsUi : WindowMediatorSubscriberBase { var icon = isDisabled ? FontAwesomeIcon.VolumeOff : FontAwesomeIcon.VolumeUp; var color = isDisabled ? UIColors.Get("DimRed") : UIColors.Get("LightlessGreen"); - + ImGui.PushStyleColor(ImGuiCol.Button, color); ImGui.PushStyleColor(ImGuiCol.ButtonHovered, color * new Vector4(1.2f, 1.2f, 1.2f, 1f)); ImGui.PushStyleColor(ImGuiCol.ButtonActive, color * new Vector4(0.8f, 0.8f, 0.8f, 1f)); - + if (ImGui.Button(icon.ToIconString(), new Vector2(buttonWidth, 0))) { bool newDisabled = !isDisabled; @@ -4303,16 +4512,16 @@ public class SettingsUi : WindowMediatorSubscriberBase } _configService.Save(); } - + ImGui.PopStyleColor(3); } UiSharedService.AttachToolTip(isDisabled ? "Sound is disabled - click to enable" : "Sound is enabled - click to disable"); - + // Reset button ImGui.SameLine(); using var resetId = ImRaii.PushId($"Reset_{typeIndex}"); bool isDefault = currentSoundId == defaultSoundId; - + using (ImRaii.Disabled(isDefault)) { using (ImRaii.PushFont(UiBuilder.IconFont)) @@ -4337,6 +4546,4 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTable(); } } -} - - +} \ No newline at end of file diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index 6ef21d5..22e42aa 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -1,13 +1,21 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Colors; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiSeStringRenderer; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; +using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.Group; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Services; +using LightlessSync.UI.Tags; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; using System.Numerics; namespace LightlessSync.UI; @@ -15,167 +23,1212 @@ namespace LightlessSync.UI; public class StandaloneProfileUi : WindowMediatorSubscriberBase { private readonly LightlessProfileManager _lightlessProfileManager; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly ServerConfigurationManager _serverManager; + private readonly ProfileTagService _profileTagService; private readonly UiSharedService _uiSharedService; - private bool _adjustedForScrollBars = false; + private readonly UserData? _userData; + private readonly GroupFullInfoDto? _groupInfo; + private readonly GroupData? _groupData; + private readonly bool _isGroupProfile; + private readonly bool _isLightfinderContext; + private readonly string? _lightfinderCid; private byte[] _lastProfilePicture = []; private byte[] _lastSupporterPicture = []; + private byte[] _lastBannerPicture = []; private IDalamudTextureWrap? _supporterTextureWrap; private IDalamudTextureWrap? _textureWrap; + private IDalamudTextureWrap? _bannerTextureWrap; + private bool _bannerTextureLoaded; + private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + private readonly List _seResolvedSegments = new(); + private const float MaxHeightMultiplier = 2.5f; + private const float DescriptionMaxVisibleLines = 12f; + private const string UserDescriptionPlaceholder = "-- User has no description set --"; + private const string GroupDescriptionPlaceholder = "-- Syncshell has no description set --"; + private float _lastComputedWindowHeight = -1f; - public StandaloneProfileUi(ILogger logger, LightlessMediator mediator, UiSharedService uiBuilder, - ServerConfigurationManager serverManager, LightlessProfileManager lightlessProfileManager, PairManager pairManager, Pair pair, + public StandaloneProfileUi( + ILogger logger, + LightlessMediator mediator, + UiSharedService uiBuilder, + ServerConfigurationManager serverManager, + ProfileTagService profileTagService, + LightlessProfileManager lightlessProfileManager, + PairUiService pairUiService, + Pair? pair, + UserData? userData, + GroupFullInfoDto? groupInfo, + bool isLightfinderContext, + string? lightfinderCid, PerformanceCollectorService performanceCollector) - : base(logger, mediator, "Lightless Profile of " + pair.UserData.AliasOrUID + "##LightlessSyncStandaloneProfileUI" + pair.UserData.AliasOrUID, performanceCollector) + : base(logger, mediator, BuildWindowTitle(userData, groupInfo, isLightfinderContext), performanceCollector) { _uiSharedService = uiBuilder; _serverManager = serverManager; + _profileTagService = profileTagService; _lightlessProfileManager = lightlessProfileManager; Pair = pair; - _pairManager = pairManager; - Flags = ImGuiWindowFlags.NoResize | ImGuiWindowFlags.AlwaysAutoResize; + _pairUiService = pairUiService; + _userData = userData; + _groupInfo = groupInfo; + _groupData = groupInfo?.Group; + _isGroupProfile = groupInfo is not null; + _isLightfinderContext = isLightfinderContext; + _lightfinderCid = lightfinderCid; - var spacing = ImGui.GetStyle().ItemSpacing; + Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize; - Size = new(512 + spacing.X * 3 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 512); + var fixedSize = new Vector2(840f, 525f) * ImGuiHelpers.GlobalScale; + Size = fixedSize; + SizeCondition = ImGuiCond.Always; + SizeConstraints = new() + { + MinimumSize = fixedSize, + MaximumSize = new Vector2(fixedSize.X, fixedSize.Y * MaxHeightMultiplier) + }; IsOpen = true; } - public Pair Pair { get; init; } + public Pair? Pair { get; } + public bool IsGroupProfile => _isGroupProfile; + public GroupData? ProfileGroupData => _groupData; + public bool IsLightfinderContext => _isLightfinderContext; + public string? LightfinderCid => _lightfinderCid; + public UserData ProfileUserData => _userData ?? throw new InvalidOperationException("ProfileUserData is only available for user profiles."); + + public void SetTagColorTheme(Vector4? background, Vector4? border) + { + if (background.HasValue) + _tagBackgroundColor = background.Value; + if (border.HasValue) + _tagBorderColor = border.Value; + } + + private static Vector4 ResolveThemeColor(string colorName, Vector4 fallback) + { + try + { + return UIColors.Get(colorName); + } + catch (ArgumentException) + { + // fallback when the color key is not registered + } + + return fallback; + } + + private static string BuildWindowTitle(UserData? userData, GroupFullInfoDto? groupInfo, bool isLightfinderContext) + { + if (groupInfo is not null) + { + var alias = groupInfo.GroupAliasOrGID; + return $"Syncshell Profile of {alias}##LightlessSyncStandaloneGroupProfileUI{groupInfo.Group.GID}"; + } + + if (userData is null) + return "Lightless Profile##LightlessSyncStandaloneProfileUI"; + + var name = userData.AliasOrUID; + var suffix = isLightfinderContext ? " (Lightfinder)" : string.Empty; + return $"Lightless Profile of {name}{suffix}##LightlessSyncStandaloneProfileUI{name}"; + } protected override void DrawInternal() { try { - var spacing = ImGui.GetStyle().ItemSpacing; - - var lightlessProfile = _lightlessProfileManager.GetLightlessUserProfile(Pair.UserData); - - if (_textureWrap == null || !lightlessProfile.ImageData.Value.SequenceEqual(_lastProfilePicture)) + if (_isGroupProfile) { - _textureWrap?.Dispose(); - _lastProfilePicture = lightlessProfile.ImageData.Value; - _textureWrap = _uiSharedService.LoadImage(_lastProfilePicture); + DrawGroupProfileWindow(); + return; } - if (_supporterTextureWrap == null || !lightlessProfile.SupporterImageData.Value.SequenceEqual(_lastSupporterPicture)) + if (_userData is null) + return; + + var userData = _userData; + var scale = ImGuiHelpers.GlobalScale; + var viewport = ImGui.GetMainViewport(); + var linked = !_isLightfinderContext + && Pair is null + && ProfileEditorLayoutCoordinator.IsActive(userData.UID); + var baseSize = ProfileEditorLayoutCoordinator.GetProfileSize(scale); + var baseWidth = baseSize.X; + var minHeight = baseSize.Y; + var maxAllowedHeight = minHeight * MaxHeightMultiplier; + var targetHeight = _lastComputedWindowHeight > 0f + ? Math.Clamp(_lastComputedWindowHeight, minHeight, maxAllowedHeight) + : minHeight; + var desiredSize = new Vector2(baseWidth, targetHeight); + Size = desiredSize; + if (linked) + { + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + + var currentPos = ImGui.GetWindowPos(); + if (IsWindowBeingDragged()) + ProfileEditorLayoutCoordinator.UpdateAnchorFromProfile(currentPos); + + var desiredPos = ProfileEditorLayoutCoordinator.GetProfilePosition(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) + ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); + + if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); + } + else + { + var defaultPosition = viewport.WorkPos + (new Vector2(50f, 70f) * scale); + ImGui.SetWindowPos(defaultPosition, ImGuiCond.FirstUseEver); + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); + } + + var profile = _lightlessProfileManager.GetLightlessProfile(userData); + IReadOnlyList profileTags = profile.Tags.Count > 0 + ? _profileTagService.ResolveTags(profile.Tags) + : Array.Empty(); + + if (_textureWrap == null || !profile.ImageData.Value.SequenceEqual(_lastProfilePicture)) + { + _textureWrap?.Dispose(); + _textureWrap = null; + _lastProfilePicture = profile.ImageData.Value; + ResetBannerTexture(); + if (_lastProfilePicture.Length > 0) + { + _textureWrap = _uiSharedService.LoadImage(_lastProfilePicture); + } + } + + if (_supporterTextureWrap == null || !profile.SupporterImageData.Value.SequenceEqual(_lastSupporterPicture)) { _supporterTextureWrap?.Dispose(); _supporterTextureWrap = null; - if (!string.IsNullOrEmpty(lightlessProfile.Base64SupporterPicture)) + if (!string.IsNullOrEmpty(profile.Base64SupporterPicture)) { - _lastSupporterPicture = lightlessProfile.SupporterImageData.Value; - _supporterTextureWrap = _uiSharedService.LoadImage(_lastSupporterPicture); + _lastSupporterPicture = profile.SupporterImageData.Value; + if (_lastSupporterPicture.Length > 0) + { + _supporterTextureWrap = _uiSharedService.LoadImage(_lastSupporterPicture); + } } } + var bannerBytes = profile.BannerImageData.Value; + if (!_lastBannerPicture.SequenceEqual(bannerBytes)) + { + ResetBannerTexture(); + _lastBannerPicture = bannerBytes; + } + + string? noteText = null; + string statusLabel = _isLightfinderContext ? "Exploring" : "Offline"; + string? visiblePlayerName = null; + bool directPair = false; + bool youPaused = false; + bool theyPaused = false; + List syncshellLines = new(); + + if (!_isLightfinderContext && Pair != null) + { + var snapshot = _pairUiService.GetSnapshot(); + noteText = _serverManager.GetNoteForUid(Pair.UserData.UID); + statusLabel = Pair.IsVisible ? "Visible" : (Pair.IsOnline ? "Online" : "Offline"); + visiblePlayerName = Pair.IsVisible ? Pair.PlayerName : null; + + directPair = Pair.IsDirectlyPaired; + + var pairInfo = Pair.UserPair; + if (pairInfo != null) + { + if (directPair) + { + youPaused = pairInfo.OwnPermissions.IsPaused(); + theyPaused = pairInfo.OtherPermissions.IsPaused(); + } + + if (pairInfo.Groups.Any()) + { + foreach (var gid in pairInfo.Groups) + { + var groupLabel = snapshot.GroupsByGid.TryGetValue(gid, out var groupInfo) + ? groupInfo.GroupAliasOrGID + : gid; + var groupNote = _serverManager.GetNoteForGid(gid); + syncshellLines.Add(string.IsNullOrEmpty(groupNote) ? groupLabel : $"{groupNote} ({groupLabel})"); + } + } + } + } + + var presenceTokens = new List + { + new(statusLabel, string.Equals(statusLabel, "Offline", StringComparison.OrdinalIgnoreCase)) + }; + + if (!string.IsNullOrEmpty(visiblePlayerName)) + presenceTokens.Add(new PresenceToken(visiblePlayerName, false)); + + if (directPair) + { + presenceTokens.Add(new PresenceToken("Direct Pair", true)); + if (youPaused) + presenceTokens.Add(new PresenceToken("You paused syncing", true)); + if (theyPaused) + presenceTokens.Add(new PresenceToken("They paused syncing", true)); + } + + if (syncshellLines.Count > 0) + presenceTokens.Add(new PresenceToken($"Sharing Syncshells ({syncshellLines.Count})", false, syncshellLines, "Shared Syncshells")); + var drawList = ImGui.GetWindowDrawList(); - var rectMin = drawList.GetClipRectMin(); - var rectMax = drawList.GetClipRectMax(); - var headerSize = ImGui.GetCursorPosY() - ImGui.GetStyle().WindowPadding.Y; + var style = ImGui.GetStyle(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var bannerHeight = 260f * scale; + var portraitSize = new Vector2(180f, 180f) * scale; + var portraitBorder = 1.5f * scale; + var portraitRounding = 1f * scale; + var portraitFrameSize = portraitSize + new Vector2(portraitBorder * 2f); + var portraitOverlap = portraitSize.Y * 0.35f; + var portraitOffsetX = 35f * scale; + var infoOffsetX = portraitOffsetX + portraitFrameSize.X + style.ItemSpacing.X * 2f; + var bannerTexture = GetBannerTexture(_lastBannerPicture) ?? _textureWrap ?? _supporterTextureWrap; - using (_uiSharedService.UidFont.Push()) - UiSharedService.ColorText(Pair.UserData.AliasOrUID, UIColors.Get("LightlessBlue")); + string defaultSubtitle = !_isLightfinderContext && Pair != null && !string.IsNullOrEmpty(Pair.UserData.Alias) + ? Pair.UserData.Alias! + : _isLightfinderContext ? "Lightfinder Session" : noteText ?? string.Empty; - ImGuiHelpers.ScaledDummy(new Vector2(spacing.Y, spacing.Y)); - var textPos = ImGui.GetCursorPosY() - headerSize; - ImGui.Separator(); - var pos = ImGui.GetCursorPos() with { Y = ImGui.GetCursorPosY() - headerSize }; - ImGuiHelpers.ScaledDummy(new Vector2(256, 256 + spacing.Y)); - var postDummy = ImGui.GetCursorPosY(); - ImGui.SameLine(); - var descriptionTextSize = ImGui.CalcTextSize(lightlessProfile.Description, wrapWidth: 256f); - var descriptionChildHeight = rectMax.Y - pos.Y - rectMin.Y - spacing.Y * 2; - if (descriptionTextSize.Y > descriptionChildHeight && !_adjustedForScrollBars) - { - Size = Size!.Value with { X = Size.Value.X + ImGui.GetStyle().ScrollbarSize }; - _adjustedForScrollBars = true; - } - else if (descriptionTextSize.Y < descriptionChildHeight && _adjustedForScrollBars) - { - Size = Size!.Value with { X = Size.Value.X - ImGui.GetStyle().ScrollbarSize }; - _adjustedForScrollBars = false; - } - var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, descriptionChildHeight); - childFrame = childFrame with - { - X = childFrame.X + (_adjustedForScrollBars ? ImGui.GetStyle().ScrollbarSize : 0), - Y = childFrame.Y / ImGuiHelpers.GlobalScale - }; - if (ImGui.BeginChildFrame(1000, childFrame)) - { - using var _ = _uiSharedService.GameFont.Push(); - ImGui.TextWrapped(lightlessProfile.Description); - } - ImGui.EndChildFrame(); + bool hasVanityAlias = userData.HasVanity && !string.IsNullOrWhiteSpace(userData.Alias); + Vector4? vanityTextColor = null; + Vector4? vanityGlowColor = null; - ImGui.SetCursorPosY(postDummy); - var note = _serverManager.GetNoteForUid(Pair.UserData.UID); - if (!string.IsNullOrEmpty(note)) + if (hasVanityAlias) { - UiSharedService.ColorText(note, ImGuiColors.DalamudGrey); + if (!string.IsNullOrWhiteSpace(userData.TextColorHex)) + vanityTextColor = UIColors.HexToRgba(userData.TextColorHex); + + if (!string.IsNullOrWhiteSpace(userData.TextGlowColorHex)) + vanityGlowColor = UIColors.HexToRgba(userData.TextGlowColorHex); } - string status = Pair.IsVisible ? "Visible" : (Pair.IsOnline ? "Online" : "Offline"); - UiSharedService.ColorText(status, (Pair.IsVisible || Pair.IsOnline) ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudRed); - if (Pair.IsVisible) + + bool useVanityColors = vanityTextColor.HasValue || vanityGlowColor.HasValue; + string primaryHeaderText = hasVanityAlias ? userData.Alias! : userData.UID; + + List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = new(); + if (hasVanityAlias) { - ImGui.SameLine(); - ImGui.TextUnformatted($"({Pair.PlayerName})"); - } - if (Pair.UserPair != null) - { - ImGui.TextUnformatted("Directly paired"); - if (Pair.UserPair.OwnPermissions.IsPaused()) + secondaryHeaderLines.Add((userData.UID, useVanityColors, false)); + + if (!string.IsNullOrEmpty(defaultSubtitle) + && !string.Equals(defaultSubtitle, userData.UID, StringComparison.OrdinalIgnoreCase) + && !string.Equals(defaultSubtitle, userData.Alias, StringComparison.OrdinalIgnoreCase)) { - ImGui.SameLine(); - UiSharedService.ColorText("You: paused", UIColors.Get("LightlessYellow")); - } - if (Pair.UserPair.OtherPermissions.IsPaused()) - { - ImGui.SameLine(); - UiSharedService.ColorText("They: paused", UIColors.Get("LightlessYellow")); + secondaryHeaderLines.Add((defaultSubtitle, false, true)); } } - - if (Pair.UserPair.Groups.Any()) + else if (!string.IsNullOrEmpty(defaultSubtitle) + && !string.Equals(defaultSubtitle, userData.UID, StringComparison.OrdinalIgnoreCase)) { - ImGui.TextUnformatted("Paired through Syncshells:"); - foreach (var group in Pair.UserPair.Groups) - { - var groupNote = _serverManager.GetNoteForGid(group); - var groupName = _pairManager.GroupPairs.First(f => string.Equals(f.Key.GID, group, StringComparison.Ordinal)).Key.GroupAliasOrGID; - var groupString = string.IsNullOrEmpty(groupNote) ? groupName : $"{groupNote} ({groupName})"; - ImGui.TextUnformatted("- " + groupString); - } + secondaryHeaderLines.Add((defaultSubtitle, false, true)); + } + + var bannerScrollOffset = new Vector2(ImGui.GetScrollX(), ImGui.GetScrollY()); + var bannerMin = windowPos - bannerScrollOffset; + var bannerMax = bannerMin + new Vector2(windowSize.X, bannerHeight); + + if (bannerTexture != null) + { + drawList.AddImage( + bannerTexture.Handle, + bannerMin, + bannerMax); + } + else + { + var headerBase = ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.Header)); + var topColor = ResolveThemeColor("ProfileBodyGradientTop", Vector4.Lerp(headerBase, Vector4.One, 0.25f)); + topColor.W = 1f; + var bottomColor = ResolveThemeColor("ProfileBodyGradientBottom", Vector4.Lerp(headerBase, Vector4.Zero, 0.35f)); + bottomColor.W = 1f; + + drawList.AddRectFilledMultiColor( + bannerMin, + bannerMax, + ImGui.ColorConvertFloat4ToU32(topColor), + ImGui.ColorConvertFloat4ToU32(topColor), + ImGui.ColorConvertFloat4ToU32(bottomColor), + ImGui.ColorConvertFloat4ToU32(bottomColor)); } - var padding = ImGui.GetStyle().WindowPadding.X / 2; - bool tallerThanWide = _textureWrap.Height >= _textureWrap.Width; - var stretchFactor = tallerThanWide ? 256f * ImGuiHelpers.GlobalScale / _textureWrap.Height : 256f * ImGuiHelpers.GlobalScale / _textureWrap.Width; - var newWidth = _textureWrap.Width * stretchFactor; - var newHeight = _textureWrap.Height * stretchFactor; - var remainingWidth = (256f * ImGuiHelpers.GlobalScale - newWidth) / 2f; - var remainingHeight = (256f * ImGuiHelpers.GlobalScale - newHeight) / 2f; - drawList.AddImage(_textureWrap.Handle, new Vector2(rectMin.X + padding + remainingWidth, rectMin.Y + spacing.Y + pos.Y + remainingHeight), - new Vector2(rectMin.X + padding + remainingWidth + newWidth, rectMin.Y + spacing.Y + pos.Y + remainingHeight + newHeight)); if (_supporterTextureWrap != null) { - const float iconSize = 38; - drawList.AddImage(_supporterTextureWrap.Handle, - new Vector2(rectMax.X - iconSize - spacing.X, rectMin.Y + (textPos / 2) - (iconSize / 2)), - new Vector2(rectMax.X - spacing.X, rectMin.Y + iconSize + (textPos / 2) - (iconSize / 2))); + const float iconBaseSize = 40f; + var iconPadding = new Vector2(style.WindowPadding.X + 18f * scale, style.WindowPadding.Y + 18f * scale); + var textureWidth = MathF.Max(1f, _supporterTextureWrap.Width); + var textureHeight = MathF.Max(1f, _supporterTextureWrap.Height); + var textureMaxEdge = MathF.Max(textureWidth, textureHeight); + var iconScale = (iconBaseSize * scale) / textureMaxEdge; + var iconSize = new Vector2(textureWidth * iconScale, textureHeight * iconScale); + var iconMax = bannerMax - iconPadding; + var iconMin = iconMax - iconSize; + var backgroundPadding = 6f * scale; + var iconBackgroundMin = iconMin - new Vector2(backgroundPadding); + var iconBackgroundMax = iconMax + new Vector2(backgroundPadding); + var backgroundColor = new Vector4(0f, 0f, 0f, 0.65f); + var cornerRadius = MathF.Max(4f * scale, iconSize.Y * 0.25f); + + drawList.AddRectFilled(iconBackgroundMin, iconBackgroundMax, ImGui.GetColorU32(backgroundColor), cornerRadius); + drawList.AddImage(_supporterTextureWrap.Handle, iconMin, iconMax); } + + var contentStartY = MathF.Max(style.WindowPadding.Y, bannerHeight - portraitOverlap); + var topAreaStart = ImGui.GetCursorPos(); + + var portraitBackgroundPadding = 12f * scale; + var portraitAreaSize = portraitFrameSize + new Vector2(portraitBackgroundPadding * 2f); + var portraitAreaPos = new Vector2(style.WindowPadding.X + portraitOffsetX - portraitBackgroundPadding, contentStartY - portraitBackgroundPadding - 24f * scale); + + ImGui.SetCursorPos(portraitAreaPos); + var portraitAreaScreenPos = ImGui.GetCursorScreenPos(); + ImGui.Dummy(portraitAreaSize); + + var portraitAreaMin = portraitAreaScreenPos; + var portraitAreaMax = portraitAreaMin + portraitAreaSize; + var portraitFrameMin = portraitAreaMin + new Vector2(portraitBackgroundPadding); + var portraitFrameMax = portraitFrameMin + portraitFrameSize; + + var portraitAreaColor = style.Colors[(int)ImGuiCol.WindowBg]; + portraitAreaColor.W = MathF.Min(1f, portraitAreaColor.W + 0.2f); + drawList.AddRectFilled(portraitAreaMin, portraitAreaMax, ImGui.GetColorU32(portraitAreaColor), portraitRounding + portraitBorder + portraitBackgroundPadding); + + var portraitFrameBorder = style.Colors[(int)ImGuiCol.Border]; + + if (_textureWrap != null) + { + drawList.AddImageRounded( + _textureWrap.Handle, + portraitFrameMin + new Vector2(portraitBorder, portraitBorder), + portraitFrameMax - new Vector2(portraitBorder, portraitBorder), + Vector2.Zero, + Vector2.One, + 0xFFFFFFFF, + portraitRounding); + } + else + { + drawList.AddRect( + portraitFrameMin + new Vector2(portraitBorder, portraitBorder), + portraitFrameMax - new Vector2(portraitBorder, portraitBorder), + ImGui.GetColorU32(portraitFrameBorder), + portraitRounding); + } + + var portraitAreaLocalMin = portraitAreaMin - windowPos; + var portraitAreaLocalMax = portraitAreaMax - windowPos; + var portraitFrameLocalMin = portraitFrameMin - windowPos; + var portraitFrameLocalMax = portraitFrameMax - windowPos; + var portraitBlockBottom = windowPos.Y + portraitAreaLocalMax.Y; + + var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); + var aliasColumnX = infoOffsetX + 18f * scale; + ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); + + ImGui.BeginGroup(); + using (_uiSharedService.UidFont.Push()) + { + if (useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(primaryHeaderText, vanityTextColor, vanityGlowColor); + SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + } + else + { + ImGui.TextUnformatted(primaryHeaderText); + } + } + + foreach (var (text, useColor, disabled) in secondaryHeaderLines) + { + if (useColor && useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); + SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + } + else + { + if (disabled) + ImGui.TextDisabled(text); + else + ImGui.TextUnformatted(text); + } + } + ImGui.EndGroup(); + var namesEnd = ImGui.GetCursorPos(); + + var namesBlockBottom = windowPos.Y + namesEnd.Y; + var aliasGroupRectMin = ImGui.GetItemRectMin(); + var aliasGroupRectMax = ImGui.GetItemRectMax(); + var aliasGroupLocalMin = aliasGroupRectMin - windowPos; + var aliasGroupLocalMax = aliasGroupRectMax - windowPos; + + var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); + ImGui.SetCursorPos(tagsStartLocal); + RenderProfileTags(profileTags, scale); + var tagsEndLocal = ImGui.GetCursorPos(); + var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; + var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; + var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); + var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); + + var descriptionPreSpacing = style.ItemSpacing.Y * 1.35f; + var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionPreSpacing); + var horizontalInset = style.ItemSpacing.X * 0.5f; + var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.5f; + var descriptionSeparatorThickness = MathF.Max(1f, scale); + var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); + var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); + drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); + + var descriptionContentStartLocal = descriptionStartLocal + new Vector2(0f, descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); + ImGui.SetCursorPos(descriptionContentStartLocal); + ImGui.TextDisabled("Description"); + ImGui.SetCursorPosX(aliasColumnX); + var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; + if (descriptionRegionWidth <= 0f) + descriptionRegionWidth = 1f; + var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); + var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); + float descriptionContentHeight; + float lineHeightWithSpacing; + using (_uiSharedService.GameFont.Push()) + { + lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var measurementText = hasDescription + ? NormalizeDescriptionForMeasurement(profile.Description!) + : UserDescriptionPlaceholder; + if (string.IsNullOrWhiteSpace(measurementText)) + measurementText = UserDescriptionPlaceholder; + + descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; + if (descriptionContentHeight <= 0f) + descriptionContentHeight = lineHeightWithSpacing; + } + + var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; + var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); + + RenderDescriptionChild( + "##StandaloneProfileDescription", + new Vector2(descriptionRegionWidth, descriptionChildHeight), + hasDescription ? profile.Description : null, + UserDescriptionPlaceholder); + + var descriptionEndLocal = ImGui.GetCursorPos(); + var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; + aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); + aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); + + var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; + var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); + var presenceStartLocal = new Vector2( + portraitFrameLocalMin.X, + presenceAnchorY + presenceLabelSpacing); + ImGui.SetCursorPos(presenceStartLocal); + ImGui.TextDisabled("Presence"); + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + if (presenceTokens.Count > 0) + { + var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); + RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); + } + else + { + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + ImGui.TextDisabled("-- No presence information --"); + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); + } + + var presenceContentEnd = ImGui.GetCursorPos(); + var separatorSpacing = style.ItemSpacing.Y * 0.2f; + var separatorThickness = MathF.Max(1f, scale); + var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); + var separatorStart = windowPos + separatorStartLocal; + var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); + drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); + var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); + + var columnStartLocalY = afterSeparatorLocal.Y; + var leftColumnX = portraitFrameLocalMin.X; + var leftWrapPos = windowPos.X + aliasColumnX - style.ItemSpacing.X; + + ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); + float leftColumnEndY = columnStartLocalY; + + if (!string.IsNullOrEmpty(noteText)) + { + ImGui.TextDisabled("Notes"); + ImGui.SetCursorPosX(leftColumnX); + ImGui.PushTextWrapPos(leftWrapPos); + ImGui.TextUnformatted(noteText); + ImGui.PopTextWrapPos(); + ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); + leftColumnEndY = ImGui.GetCursorPosY(); + } + + leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); + + var columnsBottomLocal = leftColumnEndY; + var columnsBottom = windowPos.Y + columnsBottomLocal; + var topAreaBase = windowPos.Y + topAreaStart.Y; + var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); + var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); + var topAreaHeight = leftBlockBottom - topAreaBase; + if (topAreaHeight < 0f) + topAreaHeight = 0f; + + ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); + + var finalCursorY = ImGui.GetCursorPosY(); + var paddingY = ImGui.GetStyle().WindowPadding.Y; + var computedHeight = finalCursorY + paddingY; + var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); + _lastComputedWindowHeight = adjustedHeight; + + var finalSize = new Vector2(baseWidth, adjustedHeight); + Size = finalSize; + ImGui.SetWindowSize(finalSize, ImGuiCond.Always); } catch (Exception ex) { - _logger.LogWarning(ex, "Error during draw tooltip"); + _logger.LogWarning(ex, "Error during standalone profile draw"); } } + private IDalamudTextureWrap? GetIconWrap(uint iconId) + { + try + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId); + } + + return null; + } + + private void RenderDescriptionChild( + string childId, + Vector2 childSize, + string? description, + string placeholderText) + { + ImGui.PushStyleVar(ImGuiStyleVar.ChildBorderSize, 0f); + if (ImGui.BeginChild(childId, childSize, false)) + { + using (_uiSharedService.GameFont.Push()) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + if (string.IsNullOrWhiteSpace(description)) + { + ImGui.TextUnformatted(placeholderText); + } + else if (!SeStringUtils.TryRenderSeStringMarkupAtCursor(description)) + { + ImGui.TextUnformatted(description); + } + ImGui.PopTextWrapPos(); + } + } + ImGui.EndChild(); + ImGui.PopStyleVar(); + } + + private void RenderProfileTags(IReadOnlyList tags, float scale) + { + if (tags.Count == 0) + { + ImGui.TextDisabled("-- No tags set --"); + return; + } + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var startLocal = ImGui.GetCursorPos(); + var startScreen = ImGui.GetCursorScreenPos(); + float availableWidth = ImGui.GetContentRegionAvail().X; + if (availableWidth <= 0f) + availableWidth = 1f; + + float cursorX = startScreen.X; + float cursorY = startScreen.Y; + float rowHeight = 0f; + + for (int i = 0; i < tags.Count; i++) + { + var tag = tags[i]; + if (!tag.HasContent) + continue; + + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + var tagWidth = tagSize.X; + var tagHeight = tagSize.Y; + + if (cursorX > startScreen.X && cursorX + tagWidth > startScreen.X + availableWidth) + { + cursorX = startScreen.X; + cursorY += rowHeight + style.ItemSpacing.Y; + rowHeight = 0f; + } + + var tagPos = new Vector2(cursorX, cursorY); + ImGui.SetCursorScreenPos(tagPos); + ImGui.InvisibleButton($"##profileTag_{i}", tagSize); + ProfileTagRenderer.RenderTag(tag, tagPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + cursorX += tagWidth + style.ItemSpacing.X; + rowHeight = MathF.Max(rowHeight, tagHeight); + } + + var totalHeight = (cursorY + rowHeight) - startScreen.Y; + if (totalHeight < 0f) + totalHeight = 0f; + + ImGui.SetCursorPos(new Vector2(startLocal.X, startLocal.Y + totalHeight)); + } + + private void DrawGroupProfileWindow() + { + if (_groupInfo is null || _groupData is null) + return; + + var scale = ImGuiHelpers.GlobalScale; + var viewport = ImGui.GetMainViewport(); + var linked = ProfileEditorLayoutCoordinator.IsActive(_groupData.GID); + var baseSize = ProfileEditorLayoutCoordinator.GetProfileSize(scale); + var baseWidth = baseSize.X; + var minHeight = baseSize.Y; + var maxAllowedHeight = minHeight * MaxHeightMultiplier; + var targetHeight = _lastComputedWindowHeight > 0f + ? Math.Clamp(_lastComputedWindowHeight, minHeight, maxAllowedHeight) + : minHeight; + var desiredSize = new Vector2(baseWidth, targetHeight); + Size = desiredSize; + + if (linked) + { + ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); + + var currentPos = ImGui.GetWindowPos(); + if (IsWindowBeingDragged()) + ProfileEditorLayoutCoordinator.UpdateAnchorFromProfile(currentPos); + + var desiredPos = ProfileEditorLayoutCoordinator.GetProfilePosition(scale); + if (!ProfileEditorLayoutCoordinator.NearlyEquals(currentPos, desiredPos)) + ImGui.SetWindowPos(desiredPos, ImGuiCond.Always); + + if (!ProfileEditorLayoutCoordinator.NearlyEquals(ImGui.GetWindowSize(), desiredSize)) + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); + } + else + { + var defaultPosition = viewport.WorkPos + (new Vector2(50f, 70f) * scale); + ImGui.SetWindowPos(defaultPosition, ImGuiCond.FirstUseEver); + ImGui.SetWindowSize(desiredSize, ImGuiCond.Always); + } + + var profile = _lightlessProfileManager.GetLightlessGroupProfile(_groupData); + IReadOnlyList profileTags = profile.Tags.Count > 0 + ? _profileTagService.ResolveTags(profile.Tags) + : Array.Empty(); + + if (_textureWrap == null || !profile.ProfileImageData.Value.SequenceEqual(_lastProfilePicture)) + { + _textureWrap?.Dispose(); + _textureWrap = null; + _lastProfilePicture = profile.ProfileImageData.Value; + ResetBannerTexture(); + if (_lastProfilePicture.Length > 0) + { + _textureWrap = _uiSharedService.LoadImage(_lastProfilePicture); + } + } + + if (_supporterTextureWrap != null) + { + _supporterTextureWrap.Dispose(); + _supporterTextureWrap = null; + } + _lastSupporterPicture = Array.Empty(); + + var bannerBytes = profile.BannerImageData.Value; + if (!_lastBannerPicture.SequenceEqual(bannerBytes)) + { + ResetBannerTexture(); + _lastBannerPicture = bannerBytes; + } + + var noteText = _serverManager.GetNoteForGid(_groupData.GID); + + var presenceTokens = new List + { + new(profile.IsDisabled ? "Disabled" : "Active", profile.IsDisabled) + }; + + if (profile.IsNsfw) + presenceTokens.Add(new PresenceToken("NSFW", true)); + + int memberCount = 0; + List? groupMembers = null; + var snapshot = _pairUiService.GetSnapshot(); + var groupInfo = _groupInfo; + if (groupInfo is not null && snapshot.GroupsByGid.TryGetValue(groupInfo.GID, out var refreshedGroupInfo)) + { + groupInfo = refreshedGroupInfo; + } + if (groupInfo is not null && snapshot.GroupPairs.TryGetValue(groupInfo, out var pairsForGroup)) + { + groupMembers = pairsForGroup.ToList(); + memberCount = groupMembers.Count; + } + else if (groupInfo?.GroupPairUserInfos is { Count: > 0 }) + { + memberCount = groupInfo.GroupPairUserInfos.Count; + } + + string memberLabel = memberCount == 1 ? "1 Member" : $"{memberCount} Members"; + presenceTokens.Add(new PresenceToken(memberLabel, false)); + + if (groupInfo?.GroupPermissions.IsDisableInvites() ?? false) + { + presenceTokens.Add(new PresenceToken( + "Invites Locked", + true, + new[] + { + "New members cannot join while this lock is active." + }, + "Syncshell Status")); + } + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var bannerHeight = 260f * scale; + var portraitSize = new Vector2(180f, 180f) * scale; + var portraitBorder = 1.5f * scale; + var portraitRounding = 1f * scale; + var portraitFrameSize = portraitSize + new Vector2(portraitBorder * 2f); + var portraitOverlap = portraitSize.Y * 0.35f; + var portraitOffsetX = 35f * scale; + var infoOffsetX = portraitOffsetX + portraitFrameSize.X + style.ItemSpacing.X * 2f; + var bannerTexture = GetBannerTexture(_lastBannerPicture) ?? _textureWrap; + + var bannerScrollOffset = new Vector2(ImGui.GetScrollX(), ImGui.GetScrollY()); + var bannerMin = windowPos - bannerScrollOffset; + var bannerMax = bannerMin + new Vector2(windowSize.X, bannerHeight); + + if (bannerTexture != null) + { + drawList.AddImage( + bannerTexture.Handle, + bannerMin, + bannerMax); + } + else + { + var headerBase = ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.Header)); + var topColor = ResolveThemeColor("ProfileBodyGradientTop", Vector4.Lerp(headerBase, Vector4.One, 0.25f)); + topColor.W = 1f; + var bottomColor = ResolveThemeColor("ProfileBodyGradientBottom", Vector4.Lerp(headerBase, Vector4.Zero, 0.35f)); + bottomColor.W = 1f; + + drawList.AddRectFilledMultiColor( + bannerMin, + bannerMax, + ImGui.ColorConvertFloat4ToU32(topColor), + ImGui.ColorConvertFloat4ToU32(topColor), + ImGui.ColorConvertFloat4ToU32(bottomColor), + ImGui.ColorConvertFloat4ToU32(bottomColor)); + } + + var contentStartY = MathF.Max(style.WindowPadding.Y, bannerHeight - portraitOverlap); + var topAreaStart = ImGui.GetCursorPos(); + + var portraitBackgroundPadding = 12f * scale; + var portraitAreaSize = portraitFrameSize + new Vector2(portraitBackgroundPadding * 2f); + var portraitAreaPos = new Vector2(style.WindowPadding.X + portraitOffsetX - portraitBackgroundPadding, contentStartY - portraitBackgroundPadding - 24f * scale); + + ImGui.SetCursorPos(portraitAreaPos); + var portraitAreaScreenPos = ImGui.GetCursorScreenPos(); + ImGui.Dummy(portraitAreaSize); + var contentStart = ImGui.GetCursorPos(); + + var portraitAreaMin = portraitAreaScreenPos; + var portraitAreaMax = portraitAreaMin + portraitAreaSize; + var portraitFrameMin = portraitAreaMin + new Vector2(portraitBackgroundPadding); + var portraitFrameMax = portraitFrameMin + portraitFrameSize; + + var portraitAreaColor = style.Colors[(int)ImGuiCol.WindowBg]; + portraitAreaColor.W = MathF.Min(1f, portraitAreaColor.W + 0.2f); + drawList.AddRectFilled(portraitAreaMin, portraitAreaMax, ImGui.GetColorU32(portraitAreaColor), portraitRounding + portraitBorder + portraitBackgroundPadding); + var portraitFrameBorder = style.Colors[(int)ImGuiCol.Border]; + + if (_textureWrap != null) + { + drawList.AddImageRounded( + _textureWrap.Handle, + portraitFrameMin + new Vector2(portraitBorder, portraitBorder), + portraitFrameMax - new Vector2(portraitBorder, portraitBorder), + Vector2.Zero, + Vector2.One, + 0xFFFFFFFF, + portraitRounding); + } + else + { + drawList.AddRect( + portraitFrameMin + new Vector2(portraitBorder, portraitBorder), + portraitFrameMax - new Vector2(portraitBorder, portraitBorder), + ImGui.GetColorU32(portraitFrameBorder), + portraitRounding); + } + + drawList.AddRect(portraitFrameMin, portraitFrameMax, ImGui.GetColorU32(portraitFrameBorder), portraitRounding); + var portraitAreaLocalMax = portraitAreaMax - windowPos; + var portraitFrameLocalMin = portraitFrameMin - windowPos; + var portraitFrameLocalMax = portraitFrameMax - windowPos; + var portraitBlockBottom = windowPos.Y + portraitAreaLocalMax.Y; + + ImGui.SetCursorPos(contentStart); + + bool useVanityColors = false; + Vector4? vanityTextColor = null; + Vector4? vanityGlowColor = null; + string primaryHeaderText = _groupInfo.GroupAliasOrGID; + + List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = new() + { + (_groupData.GID, false, true) + }; + + if (_groupInfo.Owner is not null) + secondaryHeaderLines.Add(($"Owner: {_groupInfo.Owner.AliasOrUID}", false, true)); + + var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); + var aliasColumnX = infoOffsetX + 18f * scale; + ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); + + ImGui.BeginGroup(); + using (_uiSharedService.UidFont.Push()) + { + ImGui.TextUnformatted(primaryHeaderText); + } + + foreach (var (text, useColor, disabled) in secondaryHeaderLines) + { + if (useColor && useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); + SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + } + else + { + if (disabled) + ImGui.TextDisabled(text); + else + ImGui.TextUnformatted(text); + } + } + ImGui.EndGroup(); + var namesEnd = ImGui.GetCursorPos(); + + var aliasGroupRectMin = ImGui.GetItemRectMin(); + var aliasGroupRectMax = ImGui.GetItemRectMax(); + var aliasGroupLocalMin = aliasGroupRectMin - windowPos; + var aliasGroupLocalMax = aliasGroupRectMax - windowPos; + + var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); + ImGui.SetCursorPos(tagsStartLocal); + if (profileTags.Count > 0) + RenderProfileTags(profileTags, scale); + else + ImGui.TextDisabled("-- No tags set --"); + var tagsEndLocal = ImGui.GetCursorPos(); + var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; + var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; + var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); + var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); + + var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.35f; + var descriptionSeparatorThickness = MathF.Max(1f, scale); + var descriptionExtraOffset = _groupInfo.Owner is not null ? style.ItemSpacing.Y * 0.6f : 0f; + var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionSeparatorSpacing + descriptionExtraOffset); + var horizontalInset = style.ItemSpacing.X * 0.5f; + var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); + var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); + drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); + + var descriptionContentStartLocal = new Vector2(aliasColumnX, descriptionStartLocal.Y + descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); + ImGui.SetCursorPos(descriptionContentStartLocal); + ImGui.TextDisabled("Description"); + ImGui.SetCursorPosX(aliasColumnX); + var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; + if (descriptionRegionWidth <= 0f) + descriptionRegionWidth = 1f; + var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); + var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); + float descriptionContentHeight; + float lineHeightWithSpacing; + using (_uiSharedService.GameFont.Push()) + { + lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var measurementText = hasDescription + ? NormalizeDescriptionForMeasurement(profile.Description!) + : GroupDescriptionPlaceholder; + if (string.IsNullOrWhiteSpace(measurementText)) + measurementText = GroupDescriptionPlaceholder; + + descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; + if (descriptionContentHeight <= 0f) + descriptionContentHeight = lineHeightWithSpacing; + } + + var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; + var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); + + RenderDescriptionChild( + "##StandaloneGroupDescription", + new Vector2(descriptionRegionWidth, descriptionChildHeight), + hasDescription ? profile.Description : null, + GroupDescriptionPlaceholder); + var descriptionEndLocal = ImGui.GetCursorPos(); + var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; + aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); + aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); + + var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; + var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); + var presenceStartLocal = new Vector2(portraitFrameLocalMin.X, presenceAnchorY + presenceLabelSpacing); + ImGui.SetCursorPos(presenceStartLocal); + ImGui.TextDisabled("Presence"); + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + if (presenceTokens.Count > 0) + { + var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); + RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); + } + else + { + ImGui.TextDisabled("-- No status flags --"); + ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); + } + + var presenceContentEnd = ImGui.GetCursorPos(); + var separatorSpacing = style.ItemSpacing.Y * 0.2f; + var separatorThickness = MathF.Max(1f, scale); + var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); + var separatorStart = windowPos + separatorStartLocal; + var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); + drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); + var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); + + var columnStartLocalY = afterSeparatorLocal.Y; + var leftColumnX = portraitFrameLocalMin.X; + ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); + float leftColumnEndY = columnStartLocalY; + + if (!string.IsNullOrEmpty(noteText)) + { + ImGui.TextDisabled("Notes"); + ImGui.SetCursorPosX(leftColumnX); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + ImGui.TextUnformatted(noteText); + ImGui.PopTextWrapPos(); + ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); + leftColumnEndY = ImGui.GetCursorPosY(); + } + + leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); + + var columnsBottomLocal = leftColumnEndY; + var columnsBottom = windowPos.Y + columnsBottomLocal; + var topAreaBase = windowPos.Y + topAreaStart.Y; + var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); + var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); + var topAreaHeight = leftBlockBottom - topAreaBase; + if (topAreaHeight < 0f) + topAreaHeight = 0f; + + ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); + + var finalCursorY = ImGui.GetCursorPosY(); + var paddingY = ImGui.GetStyle().WindowPadding.Y; + var computedHeight = finalCursorY + paddingY; + var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); + _lastComputedWindowHeight = adjustedHeight; + + var finalSize = new Vector2(baseWidth, adjustedHeight); + Size = finalSize; + ImGui.SetWindowSize(finalSize, ImGuiCond.Always); + } + + private IDalamudTextureWrap? GetBannerTexture(byte[] bannerBytes) + { + if (_bannerTextureLoaded) + return _bannerTextureWrap; + + _bannerTextureLoaded = true; + + if (bannerBytes.Length == 0) + return null; + + _bannerTextureWrap = _uiSharedService.LoadImage(bannerBytes); + return _bannerTextureWrap; + } + + private void ResetBannerTexture() + { + _bannerTextureWrap?.Dispose(); + _bannerTextureWrap = null; + _lastBannerPicture = []; + _bannerTextureLoaded = false; + } + + private static bool IsWindowBeingDragged() + { + return ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) && ImGui.GetIO().MouseDown[0]; + } + + private static string NormalizeDescriptionForMeasurement(string description) + { + if (string.IsNullOrWhiteSpace(description)) + return string.Empty; + + var normalized = description.ReplaceLineEndings("\n"); + normalized = normalized + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) + .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); + + return SeStringUtils.StripMarkup(normalized); + } + private static void RenderPresenceTokens(IReadOnlyList tokens, float scale, float? maxWidth = null) + { + if (tokens.Count == 0) + return; + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + + var startPos = ImGui.GetCursorPos(); + float startX = startPos.X; + float cursorX = startX; + float cursorY = startPos.Y; + float availWidth = maxWidth ?? ImGui.GetContentRegionAvail().X; + if (availWidth <= 0f) + availWidth = ImGui.GetContentRegionAvail().X; + if (availWidth <= 0f) + availWidth = 1f; + float spacingX = style.ItemSpacing.X; + float spacingY = style.ItemSpacing.Y; + float rounding = style.FrameRounding > 0f ? style.FrameRounding : 6f * scale; + + var padding = new Vector2(8f * scale, 4f * scale); + var baseColor = new Vector4(0.16f, 0.16f, 0.16f, 0.95f); + var baseBorder = style.Colors[(int)ImGuiCol.Border]; + baseBorder.W *= 0.35f; + var alertColor = new Vector4(0.32f, 0.2f, 0.2f, 0.95f); + var alertBorder = Vector4.Lerp(baseBorder, new Vector4(0.9f, 0.5f, 0.5f, baseBorder.W), 0.6f); + var textColor = style.Colors[(int)ImGuiCol.Text]; + + float rowHeight = 0f; + + for (int i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + var textSize = ImGui.CalcTextSize(token.Text); + var tagSize = textSize + padding * 2f; + + if (cursorX > startX && cursorX + tagSize.X > startX + availWidth) + { + cursorX = startX; + cursorY += rowHeight + spacingY; + rowHeight = 0f; + } + + ImGui.SetCursorPos(new Vector2(cursorX, cursorY)); + ImGui.InvisibleButton($"##presenceTag_{i}", tagSize); + + var tagMin = ImGui.GetItemRectMin(); + var tagMax = ImGui.GetItemRectMax(); + + var fillColor = token.Emphasis ? alertColor : baseColor; + var borderColor = token.Emphasis ? alertBorder : baseBorder; + + drawList.AddRectFilled(tagMin, tagMax, ImGui.GetColorU32(fillColor), rounding); + drawList.AddRect(tagMin, tagMax, ImGui.GetColorU32(borderColor), rounding); + drawList.AddText(tagMin + padding, ImGui.GetColorU32(textColor), token.Text); + + if (token.Tooltip is { Count: > 0 }) + { + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + if (!string.IsNullOrEmpty(token.TooltipTitle)) + { + ImGui.TextUnformatted(token.TooltipTitle); + ImGui.Separator(); + } + + foreach (var line in token.Tooltip) + ImGui.TextUnformatted(line); + ImGui.EndTooltip(); + } + } + + cursorX += tagSize.X + spacingX; + rowHeight = MathF.Max(rowHeight, tagSize.Y); + } + + ImGui.SetCursorPos(new Vector2(startX, cursorY + rowHeight)); + ImGui.Dummy(new Vector2(0f, spacingY * 0.25f)); + } + public override void OnClose() { + if (!_isGroupProfile + && !_isLightfinderContext + && Pair is null + && _userData is not null + && ProfileEditorLayoutCoordinator.IsActive(_userData.UID)) + { + ProfileEditorLayoutCoordinator.Disable(_userData.UID); + } + else if (_isGroupProfile + && _groupData is not null + && ProfileEditorLayoutCoordinator.IsActive(_groupData.GID)) + { + ProfileEditorLayoutCoordinator.Disable(_groupData.GID); + } Mediator.Publish(new RemoveWindowMessage(this)); } + + private readonly record struct PresenceToken( + string Text, + bool Emphasis, + IReadOnlyList? Tooltip = null, + string? TooltipTitle = null); } \ No newline at end of file diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index d3d8b68..3da7455 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -38,7 +38,7 @@ internal static class MainStyle new("color.border", "Border", () => Rgba(65, 65, 65, 255), ImGuiCol.Border), new("color.borderShadow", "Border Shadow", () => Rgba(0, 0, 0, 150), ImGuiCol.BorderShadow), new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), - new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 255), ImGuiCol.FrameBgHovered), + new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered), new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive), new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg), new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive), diff --git a/LightlessSync/UI/Style/Selune.cs b/LightlessSync/UI/Style/Selune.cs new file mode 100644 index 0000000..f89a1f0 --- /dev/null +++ b/LightlessSync/UI/Style/Selune.cs @@ -0,0 +1,1006 @@ +using System; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using LightlessSync.UI; + +// imagine putting this goober name here + +namespace LightlessSync.UI.Style; + +public enum SeluneGradientMode +{ + Vertical, + Horizontal, + Both, +} + +public enum SeluneHighlightMode +{ + Horizontal, + Vertical, + Both, + Point, +} + +public sealed class SeluneGradientSettings +{ + public Vector4 GradientColor { get; init; } = UIColors.Get("LightlessPurple"); + public Vector4? HighlightColor { get; init; } + public float GradientPeakOpacity { get; init; } = 0.07f; + public float HighlightPeakAlpha { get; init; } = 0.13f; + public float HighlightEdgeAlpha { get; init; } = 0f; + public float HighlightMidpoint { get; init; } = 0.45f; + public float MinimumHighlightHalfHeight { get; init; } = 25f; + public float MinimumHighlightHalfWidth { get; init; } = 25f; + public float HighlightFadeInSpeed { get; init; } = 14f; + public float HighlightFadeOutSpeed { get; init; } = 8f; + public float HighlightBorderThickness { get; init; } = 10f; + public float HighlightBorderRounding { get; init; } = 8f; + public SeluneGradientMode BackgroundMode { get; init; } = SeluneGradientMode.Vertical; + public SeluneHighlightMode HighlightMode { get; init; } = SeluneHighlightMode.Horizontal; + + public static SeluneGradientSettings Default + => new() + { + GradientColor = UIColors.Get("LightlessPurple"), + HighlightColor = UIColors.Get("LightlessPurple"), + }; +} + +public static class Selune +{ + [ThreadStatic] private static SeluneCanvas? _activeCanvas; + + public static SeluneCanvas Begin(SeluneBrush brush, ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, SeluneGradientSettings? settings = null) + { + var canvas = new SeluneCanvas(brush, drawList, windowPos, windowSize, settings ?? SeluneGradientSettings.Default, _activeCanvas); + _activeCanvas = canvas; + return canvas; + } + + internal static void Release(SeluneCanvas canvas) + { + if (_activeCanvas == canvas) + _activeCanvas = canvas.Previous; + } + + public static void RegisterHighlight( + Vector2 rectMin, + Vector2 rectMax, + SeluneHighlightMode? modeOverride = null, + bool borderOnly = false, + float? borderThicknessOverride = null, + bool exactSize = false, + bool clipToElement = false, + Vector2? clipPadding = null, + float? roundingOverride = null, + bool spanFullWidth = false, + Vector4? highlightColorOverride = null, + float? highlightAlphaOverride = null) + => _activeCanvas?.RegisterHighlight( + rectMin, + rectMax, + modeOverride, + borderOnly, + borderThicknessOverride, + exactSize, + clipToElement, + clipPadding, + roundingOverride, + spanFullWidth, + highlightColorOverride, + highlightAlphaOverride); +} + +public sealed class SeluneBrush +{ + private Vector2? _highlightCenter; + private Vector2 _highlightHalfSize; + private SeluneHighlightMode _highlightMode; + private bool _highlightBorderOnly; + private float _borderThickness; + private bool _useClipRect; + private Vector2 _clipMin; + private Vector2 _clipMax; + private float _highlightRounding; + private bool _highlightUsedThisFrame; + private float _highlightIntensity; + private Vector4? _highlightColorOverride; + private float? _highlightAlphaOverride; + + internal void BeginFrame() + { + _highlightUsedThisFrame = false; + } + + internal void RegisterHighlight(Vector2 center, Vector2 halfSize, SeluneHighlightMode mode, bool borderOnly, float borderThickness, bool useClipRect, Vector2 clipMin, Vector2 clipMax, float rounding, Vector4? highlightColorOverride, float? highlightAlphaOverride) + { + if (halfSize.X <= 0f || halfSize.Y <= 0f) + return; + + _highlightUsedThisFrame = true; + _highlightCenter = center; + _highlightHalfSize = halfSize; + _highlightMode = mode; + _highlightBorderOnly = borderOnly; + _borderThickness = borderOnly ? Math.Max(borderThickness, 0f) : 0f; + _useClipRect = useClipRect; + _clipMin = clipMin; + _clipMax = clipMax; + _highlightRounding = rounding; + _highlightColorOverride = highlightColorOverride; + _highlightAlphaOverride = highlightAlphaOverride; + } + + internal void UpdateFade(float deltaTime, SeluneGradientSettings settings) + { + if (deltaTime <= 0f) + return; + + if (_highlightUsedThisFrame) + { + _highlightIntensity = MathF.Min(1f, _highlightIntensity + deltaTime * settings.HighlightFadeInSpeed); + } + else + { + _highlightIntensity = MathF.Max(0f, _highlightIntensity - deltaTime * settings.HighlightFadeOutSpeed); + + if (_highlightIntensity <= 0.001f) + { + ResetHighlightState(); + } + } + } + + internal SeluneHighlightRenderState GetRenderState() + => new( + _highlightCenter, + _highlightHalfSize, + _highlightMode, + _highlightBorderOnly, + _borderThickness, + _useClipRect, + _clipMin, + _clipMax, + _highlightRounding, + _highlightIntensity, + _highlightColorOverride, + _highlightAlphaOverride); + + private void ResetHighlightState() + { + _highlightCenter = null; + _highlightHalfSize = Vector2.Zero; + _highlightBorderOnly = false; + _borderThickness = 0f; + _useClipRect = false; + _highlightRounding = 0f; + _highlightColorOverride = null; + _highlightAlphaOverride = null; + } +} + +internal readonly struct SeluneHighlightRenderState +{ + public SeluneHighlightRenderState( + Vector2? center, + Vector2 halfSize, + SeluneHighlightMode mode, + bool borderOnly, + float borderThickness, + bool useClipRect, + Vector2 clipMin, + Vector2 clipMax, + float rounding, + float intensity, + Vector4? colorOverride, + float? alphaOverride) + { + Center = center; + HalfSize = halfSize; + Mode = mode; + BorderOnly = borderOnly; + BorderThickness = borderThickness; + UseClipRect = useClipRect; + ClipMin = clipMin; + ClipMax = clipMax; + Rounding = rounding; + Intensity = intensity; + ColorOverride = colorOverride; + AlphaOverride = alphaOverride; + } + + public Vector2? Center { get; } + public Vector2 HalfSize { get; } + public SeluneHighlightMode Mode { get; } + public bool BorderOnly { get; } + public float BorderThickness { get; } + public bool UseClipRect { get; } + public Vector2 ClipMin { get; } + public Vector2 ClipMax { get; } + public float Rounding { get; } + public float Intensity { get; } + public Vector4? ColorOverride { get; } + public float? AlphaOverride { get; } + + public bool HasHighlight => Center.HasValue && HalfSize.X > 0f && HalfSize.Y > 0f && Intensity > 0.001f; +} + +public sealed class SeluneCanvas : IDisposable +{ + private readonly SeluneBrush _brush; + private readonly ImDrawListPtr _drawList; + private readonly Vector2 _windowPos; + private readonly Vector2 _windowSize; + private readonly SeluneGradientSettings _settings; + private bool _fadeUpdatedThisFrame; + + internal SeluneCanvas? Previous { get; } + + internal SeluneCanvas(SeluneBrush brush, ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, SeluneGradientSettings settings, SeluneCanvas? previous) + { + _brush = brush; + _drawList = drawList; + _windowPos = windowPos; + _windowSize = windowSize; + _settings = settings; + Previous = previous; + _fadeUpdatedThisFrame = false; + + _brush.BeginFrame(); + } + + public void DrawGradient(float gradientTopY, float gradientBottomY, float deltaTime) + { + DrawInternal(gradientTopY, gradientBottomY, deltaTime, true, true); + } + + public void DrawHighlightOnly(float gradientTopY, float gradientBottomY, float deltaTime) + { + DrawInternal(gradientTopY, gradientBottomY, deltaTime, false, true); + } + + public void DrawHighlightOnly(float deltaTime) + => DrawHighlightOnly(_windowPos.Y, _windowPos.Y + _windowSize.Y, deltaTime); + + public void Animate(float deltaTime) + { + UpdateFadeOnce(deltaTime); + } + + private void UpdateFadeOnce(float deltaTime) + { + if (_fadeUpdatedThisFrame) + return; + + _brush.UpdateFade(deltaTime, _settings); + _fadeUpdatedThisFrame = true; + } + + private void DrawInternal(float gradientTopY, float gradientBottomY, float deltaTime, bool drawBackground, bool drawHighlight) + { + UpdateFadeOnce(deltaTime); + + SeluneRenderer.DrawGradient( + _drawList, + _windowPos, + _windowSize, + gradientTopY, + gradientBottomY, + _brush.GetRenderState(), + _settings, + drawBackground, + drawHighlight); + } + + internal void RegisterHighlight( + Vector2 rectMin, + Vector2 rectMax, + SeluneHighlightMode? modeOverride, + bool borderOnly, + float? borderThicknessOverride, + bool exactSize, + bool clipToElement, + Vector2? clipPadding, + float? roundingOverride, + bool spanFullWidth, + Vector4? highlightColorOverride, + float? highlightAlphaOverride) + { + if (spanFullWidth) + { + rectMin.X = _windowPos.X; + rectMax.X = _windowPos.X + _windowSize.X; + } + + var size = rectMax - rectMin; + if (size.X <= 0f || size.Y <= 0f) + return; + + var center = rectMin + size * 0.5f; + var halfWidth = exactSize ? size.X * 0.5f : Math.Max(size.X * 0.5f, _settings.MinimumHighlightHalfWidth * ImGuiHelpers.GlobalScale); + var halfHeight = exactSize ? size.Y * 0.5f : Math.Max(size.Y * 0.5f, _settings.MinimumHighlightHalfHeight * ImGuiHelpers.GlobalScale); + var mode = modeOverride ?? _settings.HighlightMode; + var thickness = borderOnly ? (borderThicknessOverride ?? _settings.HighlightBorderThickness) * ImGuiHelpers.GlobalScale : 0f; + var useClip = clipToElement; + var padding = clipPadding ?? (borderOnly && thickness > 0f + ? new Vector2(thickness) + : Vector2.Zero); + var clipMin = rectMin - padding; + var clipMax = rectMax + padding; + var rounding = (roundingOverride ?? _settings.HighlightBorderRounding) * ImGuiHelpers.GlobalScale; + + _brush.RegisterHighlight(center, new Vector2(halfWidth, halfHeight), mode, borderOnly, thickness, useClip, clipMin, clipMax, rounding, highlightColorOverride, highlightAlphaOverride); + _fadeUpdatedThisFrame = false; + } + + public void Dispose() + => Selune.Release(this); +} + // i wonder which sync will copy this shitty code now +internal static class SeluneRenderer +{ + public static void DrawGradient( + ImDrawListPtr drawList, + Vector2 windowPos, + Vector2 windowSize, + float gradientTopY, + float gradientBottomY, + SeluneHighlightRenderState highlightState, + SeluneGradientSettings settings, + bool drawBackground = true, + bool drawHighlight = true) + { + var gradientLeft = windowPos.X; + var gradientRight = windowPos.X + windowSize.X; + var windowBottomY = windowPos.Y + windowSize.Y; + var clampedTopY = MathF.Max(gradientTopY, windowPos.Y); + var clampedBottomY = MathF.Min(gradientBottomY, windowBottomY); + + if (clampedBottomY <= clampedTopY) + return; + + var color = settings.GradientColor; + var topColorVec = new Vector4(color.X, color.Y, color.Z, 0f); + var bottomColorVec = new Vector4(color.X, color.Y, color.Z, 0f); + var midColorVec = new Vector4(color.X, color.Y, color.Z, settings.GradientPeakOpacity); + + if (drawBackground) + { + DrawBackground( + drawList, + gradientLeft, + gradientRight, + clampedTopY, + clampedBottomY, + topColorVec, + midColorVec, + bottomColorVec, + settings.BackgroundMode); + } + + if (!drawHighlight) + return; + + DrawHighlight( + drawList, + gradientLeft, + gradientRight, + clampedTopY, + clampedBottomY, + highlightState, + settings); + } + + private static void DrawBackground( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector4 topColorVec, + Vector4 midColorVec, + Vector4 bottomColorVec, + SeluneGradientMode mode) + { + switch (mode) + { + case SeluneGradientMode.Vertical: + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + break; + case SeluneGradientMode.Horizontal: + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + break; + case SeluneGradientMode.Both: + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + break; + } + } + + private static void DrawVerticalBackground( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector4 topColorVec, + Vector4 midColorVec, + Vector4 bottomColorVec) + { + var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec); + var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); + var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec); + + var midY = clampedTopY + (clampedBottomY - clampedTopY) * 0.035f; + drawList.AddRectFilledMultiColor( + new Vector2(gradientLeft, clampedTopY), + new Vector2(gradientRight, midY), + topColor, + topColor, + midColor, + midColor); + + drawList.AddRectFilledMultiColor( + new Vector2(gradientLeft, midY), + new Vector2(gradientRight, clampedBottomY), + midColor, + midColor, + bottomColor, + bottomColor); + } + + private static void DrawHorizontalBackground( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector4 leftColorVec, + Vector4 midColorVec, + Vector4 rightColorVec) + { + var leftColor = ImGui.ColorConvertFloat4ToU32(leftColorVec); + var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); + var rightColor = ImGui.ColorConvertFloat4ToU32(rightColorVec); + + var midX = gradientLeft + (gradientRight - gradientLeft) * 0.035f; + drawList.AddRectFilledMultiColor( + new Vector2(gradientLeft, clampedTopY), + new Vector2(midX, clampedBottomY), + leftColor, + midColor, + midColor, + leftColor); + + drawList.AddRectFilledMultiColor( + new Vector2(midX, clampedTopY), + new Vector2(gradientRight, clampedBottomY), + midColor, + rightColor, + rightColor, + midColor); + } + + private static void DrawHighlight( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + SeluneHighlightRenderState highlightState, + SeluneGradientSettings settings) + { + if (!highlightState.HasHighlight) + return; + + var highlightColor = highlightState.ColorOverride ?? settings.HighlightColor ?? settings.GradientColor; + var clampedIntensity = Math.Clamp(highlightState.Intensity, 0f, 1f); + var alphaScale = Math.Clamp(highlightState.AlphaOverride ?? 1f, 0f, 1f); + var peakAlpha = settings.HighlightPeakAlpha * clampedIntensity * alphaScale; + var edgeAlpha = settings.HighlightEdgeAlpha * clampedIntensity * alphaScale; + + if (peakAlpha <= 0f && edgeAlpha <= 0f) + return; + + var highlightEdgeVec = new Vector4(highlightColor.X, highlightColor.Y, highlightColor.Z, edgeAlpha); + var highlightPeakVec = new Vector4(highlightColor.X, highlightColor.Y, highlightColor.Z, peakAlpha); + var center = highlightState.Center!.Value; + var halfSize = highlightState.HalfSize; + + if (highlightState.UseClipRect) + drawList.PushClipRect(highlightState.ClipMin, highlightState.ClipMax, true); + + switch (highlightState.Mode) + { + case SeluneHighlightMode.Horizontal: + DrawHorizontalHighlight( + drawList, + gradientLeft, + gradientRight, + clampedTopY, + clampedBottomY, + center, + halfSize, + highlightEdgeVec, + highlightPeakVec, + settings.HighlightMidpoint, + highlightState.BorderOnly, + highlightState.BorderThickness, + highlightState.Rounding); + break; + + case SeluneHighlightMode.Vertical: + DrawVerticalHighlight( + drawList, + gradientLeft, + gradientRight, + clampedTopY, + clampedBottomY, + center, + halfSize, + highlightEdgeVec, + highlightPeakVec, + settings.HighlightMidpoint, + highlightState.BorderOnly, + highlightState.BorderThickness, + highlightState.Rounding); + break; + + case SeluneHighlightMode.Both: + DrawCombinedHighlight( + drawList, + gradientLeft, + gradientRight, + clampedTopY, + clampedBottomY, + center, + halfSize, + highlightEdgeVec, + highlightPeakVec, + highlightState.BorderOnly, + highlightState.BorderThickness, + highlightState.Rounding); + break; + + case SeluneHighlightMode.Point: + DrawPointHighlight( + drawList, + center, + halfSize, + highlightEdgeVec, + highlightPeakVec, + highlightState.BorderOnly, + highlightState.BorderThickness); + break; + } + + if (highlightState.UseClipRect) + drawList.PopClipRect(); + } + + private static void DrawHorizontalHighlight( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector2 center, + Vector2 halfSize, + Vector4 edgeColor, + Vector4 peakColor, + float midpoint, + bool borderOnly, + float borderThickness, + float rounding) + { + var highlightTop = MathF.Max(clampedTopY, center.Y - halfSize.Y); + var highlightBottom = MathF.Min(clampedBottomY, center.Y + halfSize.Y); + if (highlightBottom <= highlightTop) + return; + + var highlightLeft = MathF.Max(gradientLeft, center.X - halfSize.X); + var highlightRight = MathF.Min(gradientRight, center.X + halfSize.X); + + if (highlightRight <= highlightLeft || highlightBottom <= highlightTop) + return; + + if (!borderOnly || borderThickness <= 0f) + { + DrawHorizontalHighlightRect( + drawList, + highlightLeft, + highlightRight, + highlightTop, + highlightBottom, + edgeColor, + peakColor, + midpoint, + 1f); + return; + } + + var innerTop = MathF.Min(highlightBottom, MathF.Max(highlightTop, center.Y - MathF.Max(halfSize.Y - borderThickness, 0f))); + var innerBottom = MathF.Max(highlightTop, MathF.Min(highlightBottom, center.Y + MathF.Max(halfSize.Y - borderThickness, 0f))); + var edgeU32 = ImGui.ColorConvertFloat4ToU32(edgeColor); + var peakU32 = ImGui.ColorConvertFloat4ToU32(peakColor); + + if (innerTop > highlightTop) + { + drawList.AddRectFilledMultiColor( + new Vector2(highlightLeft, highlightTop), + new Vector2(highlightRight, innerTop), + edgeU32, + edgeU32, + peakU32, + peakU32); + } + + if (innerBottom < highlightBottom) + { + drawList.AddRectFilledMultiColor( + new Vector2(highlightLeft, innerBottom), + new Vector2(highlightRight, highlightBottom), + peakU32, + peakU32, + edgeU32, + edgeU32); + } + } + + private static void DrawVerticalHighlight( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector2 center, + Vector2 halfSize, + Vector4 edgeColor, + Vector4 peakColor, + float midpoint, + bool borderOnly, + float borderThickness, + float rounding) + { + var highlightTop = MathF.Max(clampedTopY, center.Y - halfSize.Y); + var highlightBottom = MathF.Min(clampedBottomY, center.Y + halfSize.Y); + var highlightLeft = MathF.Max(gradientLeft, center.X - halfSize.X); + var highlightRight = MathF.Min(gradientRight, center.X + halfSize.X); + + if (highlightRight <= highlightLeft || highlightBottom <= highlightTop) + return; + + if (!borderOnly || borderThickness <= 0f) + { + DrawVerticalHighlightRect( + drawList, + highlightLeft, + highlightRight, + highlightTop, + highlightBottom, + edgeColor, + peakColor, + midpoint, + 1f); + return; + } + + var innerLeft = MathF.Min(highlightRight, MathF.Max(highlightLeft, center.X - MathF.Max(halfSize.X - borderThickness, 0f))); + var innerRight = MathF.Max(highlightLeft, MathF.Min(highlightRight, center.X + MathF.Max(halfSize.X - borderThickness, 0f))); + var edgeU32 = ImGui.ColorConvertFloat4ToU32(edgeColor); + var peakU32 = ImGui.ColorConvertFloat4ToU32(peakColor); + + if (innerLeft > highlightLeft) + { + drawList.AddRectFilledMultiColor( + new Vector2(highlightLeft, highlightTop), + new Vector2(innerLeft, highlightBottom), + edgeU32, + peakU32, + peakU32, + edgeU32); + } + + if (innerRight < highlightRight) + { + drawList.AddRectFilledMultiColor( + new Vector2(innerRight, highlightTop), + new Vector2(highlightRight, highlightBottom), + peakU32, + edgeU32, + edgeU32, + peakU32); + } + } + + private static void DrawCombinedHighlight( + ImDrawListPtr drawList, + float gradientLeft, + float gradientRight, + float clampedTopY, + float clampedBottomY, + Vector2 center, + Vector2 halfSize, + Vector4 edgeColor, + Vector4 peakColor, + bool borderOnly, + float borderThickness, + float rounding) + { + var highlightLeft = MathF.Max(gradientLeft, center.X - halfSize.X); + var highlightRight = MathF.Min(gradientRight, center.X + halfSize.X); + var highlightTop = MathF.Max(clampedTopY, center.Y - halfSize.Y); + var highlightBottom = MathF.Min(clampedBottomY, center.Y + halfSize.Y); + + if (highlightRight <= highlightLeft || highlightBottom <= highlightTop) + return; + + if (borderOnly && borderThickness > 0f) + { + DrawRoundedBorderGlow(drawList, center, halfSize, borderThickness, edgeColor, peakColor, rounding); + return; + } + + if (!borderOnly || borderThickness <= 0f) + { + const float combinedScale = 0.85f; + DrawHorizontalHighlightRect( + drawList, + highlightLeft, + highlightRight, + highlightTop, + highlightBottom, + edgeColor, + peakColor, + 0.5f, + combinedScale); + + DrawVerticalHighlightRect( + drawList, + highlightLeft, + highlightRight, + highlightTop, + highlightBottom, + edgeColor, + peakColor, + 0.5f, + combinedScale); + return; + } + + var outerLeft = MathF.Max(gradientLeft, highlightLeft - borderThickness); + var outerRight = MathF.Min(gradientRight, highlightRight + borderThickness); + var outerTop = MathF.Max(clampedTopY, highlightTop - borderThickness); + var outerBottom = MathF.Min(clampedBottomY, highlightBottom + borderThickness); + + var edge = ImGui.ColorConvertFloat4ToU32(edgeColor); + var peak = ImGui.ColorConvertFloat4ToU32(peakColor); + + if (outerTop < highlightTop) + { + drawList.AddRectFilledMultiColor( + new Vector2(outerLeft, outerTop), + new Vector2(outerRight, highlightTop), + edge, + edge, + peak, + peak); + } + + if (outerBottom > highlightBottom) + { + drawList.AddRectFilledMultiColor( + new Vector2(outerLeft, highlightBottom), + new Vector2(outerRight, outerBottom), + peak, + peak, + edge, + edge); + } + + if (outerLeft < highlightLeft) + { + drawList.AddRectFilledMultiColor( + new Vector2(outerLeft, highlightTop), + new Vector2(highlightLeft, highlightBottom), + edge, + peak, + peak, + edge); + } + + if (outerRight > highlightRight) + { + drawList.AddRectFilledMultiColor( + new Vector2(highlightRight, highlightTop), + new Vector2(outerRight, highlightBottom), + peak, + edge, + edge, + peak); + } + } + + private static void DrawPointHighlight( + ImDrawListPtr drawList, + Vector2 center, + Vector2 halfSize, + Vector4 edgeColor, + Vector4 peakColor, + bool borderOnly, + float borderThickness) + { + if (halfSize.X <= 0f || halfSize.Y <= 0f) + return; + + if (borderOnly && borderThickness > 0f) + { + DrawPointBorderGlow(drawList, center, halfSize, borderThickness, edgeColor, peakColor); + return; + } + + const int layers = 7; + for (int layer = 0; layer < layers; layer++) + { + float t = layers <= 1 ? 1f : layer / (layers - 1f); + float scale = 1f - 0.75f * t; + var scaledHalfSize = new Vector2(MathF.Max(1f, halfSize.X * scale), MathF.Max(1f, halfSize.Y * scale)); + var color = Vector4.Lerp(edgeColor, peakColor, t); + DrawEllipseFilled(drawList, center, scaledHalfSize, ImGui.ColorConvertFloat4ToU32(color)); + } + } + + private static void DrawPointBorderGlow( + ImDrawListPtr drawList, + Vector2 center, + Vector2 halfSize, + float thickness, + Vector4 edgeColor, + Vector4 peakColor) + { + int layers = Math.Max(6, (int)MathF.Ceiling(thickness)); + for (int i = 0; i < layers; i++) + { + float t = layers <= 1 ? 1f : i / (layers - 1f); + float offset = thickness * t; + var expandedHalfSize = new Vector2(MathF.Max(1f, halfSize.X + offset), MathF.Max(1f, halfSize.Y + offset)); + var color = Vector4.Lerp(peakColor, edgeColor, t); + color.W = Math.Clamp((peakColor.W * 0.8f) + (edgeColor.W - peakColor.W) * t, 0f, 1f); + DrawEllipseStroke(drawList, center, expandedHalfSize, ImGui.ColorConvertFloat4ToU32(color), 2f); + } + } + + private static void DrawEllipseFilled(ImDrawListPtr drawList, Vector2 center, Vector2 halfSize, uint color, int segments = 48) + { + if (halfSize.X <= 0f || halfSize.Y <= 0f) + return; + + BuildEllipsePath(drawList, center, halfSize, segments); + drawList.PathFillConvex(color); + } + + private static void DrawEllipseStroke(ImDrawListPtr drawList, Vector2 center, Vector2 halfSize, uint color, float thickness, int segments = 48) + { + if (halfSize.X <= 0f || halfSize.Y <= 0f) + return; + + BuildEllipsePath(drawList, center, halfSize, segments); + drawList.PathStroke(color, ImDrawFlags.None, MathF.Max(1f, thickness)); + } + + private static void BuildEllipsePath(ImDrawListPtr drawList, Vector2 center, Vector2 halfSize, int segments) + { + const float twoPi = MathF.PI * 2f; + segments = Math.Clamp(segments, 12, 96); + drawList.PathClear(); + for (int i = 0; i < segments; i++) + { + float angle = twoPi * (i / (float)segments); + var point = new Vector2( + center.X + MathF.Cos(angle) * halfSize.X, + center.Y + MathF.Sin(angle) * halfSize.Y); + drawList.PathLineTo(point); + } + } + + private static void DrawRoundedBorderGlow( + ImDrawListPtr drawList, + Vector2 center, + Vector2 halfSize, + float thickness, + Vector4 edgeColor, + Vector4 peakColor, + float rounding) + { + int layers = Math.Max(6, (int)MathF.Ceiling(thickness)); + for (int i = 0; i < layers; i++) + { + float t = layers <= 1 ? 0f : i / (layers - 1f); + float offset = thickness * t; + var min = new Vector2(center.X - halfSize.X - offset, center.Y - halfSize.Y - offset); + var max = new Vector2(center.X + halfSize.X + offset, center.Y + halfSize.Y + offset); + var color = Vector4.Lerp(peakColor, edgeColor, t); + color.W = Math.Clamp((peakColor.W * 0.8f) + (edgeColor.W - peakColor.W) * t, 0f, 1f); + drawList.AddRect(min, max, ImGui.ColorConvertFloat4ToU32(color), MathF.Max(0f, rounding + offset), ImDrawFlags.RoundCornersAll, 2f); + } + } + + private static void DrawHorizontalHighlightRect( + ImDrawListPtr drawList, + float left, + float right, + float top, + float bottom, + Vector4 edgeColor, + Vector4 peakColor, + float midpoint, + float alphaScale) + { + if (right <= left || bottom <= top) + return; + + edgeColor.W *= alphaScale; + peakColor.W *= alphaScale; + + var edge = ImGui.ColorConvertFloat4ToU32(edgeColor); + var peak = ImGui.ColorConvertFloat4ToU32(peakColor); + var highlightMid = top + (bottom - top) * midpoint; + + drawList.AddRectFilledMultiColor( + new Vector2(left, top), + new Vector2(right, highlightMid), + edge, + edge, + peak, + peak); + + drawList.AddRectFilledMultiColor( + new Vector2(left, highlightMid), + new Vector2(right, bottom), + peak, + peak, + edge, + edge); + } + + private static void DrawVerticalHighlightRect( + ImDrawListPtr drawList, + float left, + float right, + float top, + float bottom, + Vector4 edgeColor, + Vector4 peakColor, + float midpoint, + float alphaScale) + { + if (right <= left || bottom <= top) + return; + + edgeColor.W *= alphaScale; + peakColor.W *= alphaScale; + + var edge = ImGui.ColorConvertFloat4ToU32(edgeColor); + var peak = ImGui.ColorConvertFloat4ToU32(peakColor); + var highlightMid = left + (right - left) * midpoint; + + drawList.AddRectFilledMultiColor( + new Vector2(left, top), + new Vector2(highlightMid, bottom), + edge, + peak, + peak, + edge); + + drawList.AddRectFilledMultiColor( + new Vector2(highlightMid, top), + new Vector2(right, bottom), + peak, + edge, + edge, + peak); + } +} diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index be8e1d4..94d3977 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -2,7 +2,6 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.ImGuiFileDialog; -using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data; @@ -10,14 +9,13 @@ using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.UI.Handlers; +using LightlessSync.PlayerData.Pairs; using LightlessSync.WebAPI; +using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; using System.Globalization; using System.Linq; using System.Numerics; @@ -30,35 +28,28 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private readonly bool _isModerator = false; private readonly bool _isOwner = false; private readonly List _oneTimeInvites = []; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly LightlessProfileManager _lightlessProfileManager; private readonly FileDialogManager _fileDialogManager; private readonly UiSharedService _uiSharedService; private List _bannedUsers = []; private LightlessGroupProfileData? _profileData = null; - private bool _adjustedForScollBarsLocalProfile = false; - private bool _adjustedForScollBarsOnlineProfile = false; - private string _descriptionText = string.Empty; - private IDalamudTextureWrap? _pfpTextureWrap; private string _profileDescription = string.Empty; - private byte[] _profileImage = []; - private bool _showFileDialogError = false; private int _multiInvites; private string _newPassword; private bool _pwChangeSuccess; private Task? _pruneTestTask; private Task? _pruneTask; private int _pruneDays = 14; - private List _selectedTags = []; public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairManager pairManager, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager) + UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; _apiController = apiController; _uiSharedService = uiSharedService; - _pairManager = pairManager; + _pairUiService = pairUiService; _lightlessProfileManager = lightlessProfileManager; _fileDialogManager = fileDialogManager; @@ -68,14 +59,6 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _multiInvites = 30; _pwChangeSuccess = true; IsOpen = true; - Mediator.Subscribe(this, (msg) => - { - if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal)) - { - _pfpTextureWrap?.Dispose(); - _pfpTextureWrap = null; - } - }); SizeConstraints = new WindowSizeConstraints() { MinimumSize = new(700, 500), @@ -90,10 +73,13 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (!_isModerator && !_isOwner) return; _logger.LogTrace("Drawing Syncshell Admin UI for {group}", GroupFullInfo.GroupAliasOrGID); - GroupFullInfo = _pairManager.Groups[GroupFullInfo.Group]; + var snapshot = _pairUiService.GetSnapshot(); + if (snapshot.GroupsByGid.TryGetValue(GroupFullInfo.Group.GID, out var updatedInfo)) + { + GroupFullInfo = updatedInfo; + } _profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group); - GetTagsFromProfile(); using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID); using (_uiSharedService.UidFont.Push()) @@ -215,179 +201,47 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private void DrawProfile() { var profileTab = ImRaii.TabItem("Profile"); + if (!profileTab) + return; - if (profileTab) + if (_profileData != null) { - if (_uiSharedService.MediumTreeNode("Current Profile", UIColors.Get("LightlessPurple"))) + if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.Ordinal)) { - ImGui.Dummy(new Vector2(5)); - - if (!_profileImage.SequenceEqual(_profileData.ImageData.Value)) - { - _profileImage = _profileData.ImageData.Value; - _pfpTextureWrap?.Dispose(); - _pfpTextureWrap = _uiSharedService.LoadImage(_profileImage); - } - - if (!string.Equals(_profileDescription, _profileData.Description, StringComparison.OrdinalIgnoreCase)) - { - _profileDescription = _profileData.Description; - _descriptionText = _profileDescription; - } - - if (_pfpTextureWrap != null) - { - ImGui.Image(_pfpTextureWrap.Handle, ImGuiHelpers.ScaledVector2(_pfpTextureWrap.Width, _pfpTextureWrap.Height)); - } - - var spacing = ImGui.GetStyle().ItemSpacing.X; - ImGuiHelpers.ScaledRelativeSameLine(256, spacing); - using (_uiSharedService.GameFont.Push()) - { - var descriptionTextSize = ImGui.CalcTextSize(_profileData.Description, wrapWidth: 256f); - var childFrame = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 256); - if (descriptionTextSize.Y > childFrame.Y) - { - _adjustedForScollBarsOnlineProfile = true; - } - else - { - _adjustedForScollBarsOnlineProfile = false; - } - childFrame = childFrame with - { - X = childFrame.X + (_adjustedForScollBarsOnlineProfile ? ImGui.GetStyle().ScrollbarSize : 0), - }; - if (ImGui.BeginChildFrame(101, childFrame)) - { - UiSharedService.TextWrapped(_profileData.Description); - } - ImGui.EndChildFrame(); - ImGui.TreePop(); - } - var nsfw = _profileData.IsNsfw; - ImGui.BeginDisabled(); - ImGui.Checkbox("Is NSFW", ref nsfw); - ImGui.EndDisabled(); + _profileDescription = _profileData.Description; } - ImGui.Separator(); + UiSharedService.TextWrapped("Preview the Syncshell profile in a standalone window."); - if (_uiSharedService.MediumTreeNode("Profile Settings", UIColors.Get("LightlessPurple"))) + if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile")) { - ImGui.Dummy(new Vector2(5)); - ImGui.TextUnformatted($"Profile Picture:"); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) - { - _fileDialogManager.OpenFileDialog("Select new Profile picture", ".png", (success, file) => - { - if (!success) return; - _ = Task.Run(async () => - { - var fileContent = await File.ReadAllBytesAsync(file).ConfigureAwait(false); - MemoryStream ms = new(fileContent); - await using (ms.ConfigureAwait(false)) - { - var format = await Image.DetectFormatAsync(ms).ConfigureAwait(false); - if (!format.FileExtensions.Contains("png", StringComparer.OrdinalIgnoreCase)) - { - _showFileDialogError = true; - return; - } - using var image = Image.Load(fileContent); - - if (image.Width > 512 || image.Height > 512 || (fileContent.Length > 2000 * 1024)) - { - _showFileDialogError = true; - return; - } - - _showFileDialogError = false; - await _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, Convert.ToBase64String(fileContent), BannerBase64: null, IsNsfw: null, IsDisabled: null)) - .ConfigureAwait(false); - } - }); - }); - } - UiSharedService.AttachToolTip("Select and upload a new profile picture"); - ImGui.SameLine(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear uploaded profile picture")) - { - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)); - } - UiSharedService.AttachToolTip("Clear your currently uploaded profile picture"); - if (_showFileDialogError) - { - UiSharedService.ColorTextWrapped("The profile picture must be a PNG file with a maximum height and width of 256px and 250KiB size", ImGuiColors.DalamudRed); - } - ImGui.Separator(); - ImGui.TextUnformatted($"Tags:"); - var childFrameLocal = ImGuiHelpers.ScaledVector2(256 + ImGui.GetStyle().WindowPadding.X + ImGui.GetStyle().WindowBorderSize, 200); - - var allCategoryIndexes = Enum.GetValues() - .Cast() - .ToList(); - - foreach(int tag in allCategoryIndexes) - { - using (ImRaii.PushId($"tag-{tag}")) DrawTag(tag); - } - ImGui.Separator(); - var widthTextBox = 400; - var posX = ImGui.GetCursorPosX(); - ImGui.TextUnformatted($"Description {_descriptionText.Length}/1500"); - ImGui.SetCursorPosX(posX); - ImGuiHelpers.ScaledRelativeSameLine(widthTextBox, ImGui.GetStyle().ItemSpacing.X); - ImGui.TextUnformatted("Preview (approximate)"); - using (_uiSharedService.GameFont.Push()) - ImGui.InputTextMultiline("##description", ref _descriptionText, 1500, ImGuiHelpers.ScaledVector2(widthTextBox, 200)); - - ImGui.SameLine(); - - using (_uiSharedService.GameFont.Push()) - { - var descriptionTextSizeLocal = ImGui.CalcTextSize(_descriptionText, wrapWidth: 256f); - if (descriptionTextSizeLocal.Y > childFrameLocal.Y) - { - _adjustedForScollBarsLocalProfile = true; - } - else - { - _adjustedForScollBarsLocalProfile = false; - } - childFrameLocal = childFrameLocal with - { - X = childFrameLocal.X + (_adjustedForScollBarsLocalProfile ? ImGui.GetStyle().ScrollbarSize : 0), - }; - if (ImGui.BeginChildFrame(102, childFrameLocal)) - { - UiSharedService.TextWrapped(_descriptionText); - } - ImGui.EndChildFrame(); - } - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Description")) - { - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: _descriptionText, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)); - } - UiSharedService.AttachToolTip("Sets your profile description text"); - ImGui.SameLine(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Clear Description")) - { - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)); - } - UiSharedService.AttachToolTip("Clears your profile description text"); - ImGui.Separator(); - ImGui.TextUnformatted($"Profile Options:"); - var isNsfw = _profileData.IsNsfw; - if (ImGui.Checkbox("Profile is NSFW", ref isNsfw)) - { - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: null, PictureBase64: null, BannerBase64: null, IsNsfw: isNsfw, IsDisabled: null)); - } - _uiSharedService.DrawHelpText("If your profile description or image can be considered NSFW, toggle this to ON"); - ImGui.TreePop(); + Mediator.Publish(new GroupProfileOpenStandaloneMessage(GroupFullInfo)); } + UiSharedService.AttachToolTip("Opens the standalone Syncshell profile window for this group."); + + ImGuiHelpers.ScaledDummy(2f); + ImGui.TextDisabled("Profile Flags"); + ImGui.BulletText(_profileData.IsNsfw ? "Marked as NSFW" : "Marked as SFW"); + ImGui.BulletText(_profileData.IsDisabled ? "Profile disabled for viewers" : "Profile active"); + + ImGuiHelpers.ScaledDummy(2f); + _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGuiHelpers.ScaledDummy(2f); + + UiSharedService.TextWrapped("Open the syncshell profile editor to update images, description, tags, and visibility settings."); + ImGuiHelpers.ScaledDummy(2f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserEdit, "Open Syncshell Profile Editor")) + { + Mediator.Publish(new OpenGroupProfileEditorMessage(GroupFullInfo)); + } + UiSharedService.AttachToolTip("Launches the editor window and associated live preview for this syncshell."); } + else + { + UiSharedService.TextWrapped("Profile information is loading..."); + } + profileTab.Dispose(); } @@ -398,7 +252,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase { if (_uiSharedService.MediumTreeNode("User List & Administration", UIColors.Get("LightlessPurple"))) { - if (!_pairManager.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + var snapshot = _pairUiService.GetSnapshot(); + if (!snapshot.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) { UiSharedService.ColorTextWrapped("No users found in this Syncshell", ImGuiColors.DalamudYellow); } @@ -734,37 +589,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } inviteTab.Dispose(); } - private void DrawTag(int tag) - { - var HasTag = _selectedTags.Contains(tag); - var tagName = (ProfileTags)tag; - - if (ImGui.Checkbox(tagName.ToString(), ref HasTag)) - { - if (HasTag) - { - _selectedTags.Add(tag); - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)); - } - else - { - _selectedTags.Remove(tag); - _ = _apiController.GroupSetProfile(new GroupProfileDto(new GroupData(GroupFullInfo.Group.AliasOrGID), Description: null, Tags: _selectedTags.ToArray(), PictureBase64: null, BannerBase64: null, IsNsfw: null, IsDisabled: null)); - } - } - } - - private void GetTagsFromProfile() - { - if (_profileData != null) - { - _selectedTags = [.. _profileData.Tags]; - } - } - public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); - _pfpTextureWrap?.Dispose(); } } \ No newline at end of file diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index d7f5605..823f44a 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -7,13 +7,16 @@ using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; +using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; using System.Numerics; +using System.Threading.Tasks; namespace LightlessSync.UI; @@ -23,7 +26,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private readonly BroadcastService _broadcastService; private readonly UiSharedService _uiSharedService; private readonly BroadcastScannerService _broadcastScannerService; - private readonly PairManager _pairManager; + private readonly PairUiService _pairUiService; private readonly DalamudUtilService _dalamudUtilService; private readonly List _nearbySyncshells = []; @@ -43,14 +46,14 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase UiSharedService uiShared, ApiController apiController, BroadcastScannerService broadcastScannerService, - PairManager pairManager, + PairUiService pairUiService, DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; _apiController = apiController; _broadcastScannerService = broadcastScannerService; - _pairManager = pairManager; + _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; IsOpen = false; @@ -266,7 +269,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private async Task RefreshSyncshellsAsync() { var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); - _currentSyncshells = [.. _pairManager.GroupPairs.Select(g => g.Key)]; + var snapshot = _pairUiService.GetSnapshot(); + _currentSyncshells = snapshot.GroupPairs.Keys.ToList(); _recentlyJoined.RemoveWhere(gid => _currentSyncshells.Any(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); diff --git a/LightlessSync/UI/Tags/ProfileTagDefinition.cs b/LightlessSync/UI/Tags/ProfileTagDefinition.cs new file mode 100644 index 0000000..fc44aed --- /dev/null +++ b/LightlessSync/UI/Tags/ProfileTagDefinition.cs @@ -0,0 +1,30 @@ +using System.Numerics; + +namespace LightlessSync.UI.Tags; + +public readonly record struct ProfileTagDefinition( + string? Text, + string? SeStringPayload = null, + bool UseTextureSegments = false, + Vector4? BackgroundColor = null, + Vector4? BorderColor = null, + Vector4? TextColor = null) +{ + public bool HasContent => !string.IsNullOrWhiteSpace(Text) || !string.IsNullOrWhiteSpace(SeStringPayload); + public bool HasSeString => !string.IsNullOrWhiteSpace(SeStringPayload); + + public ProfileTagDefinition WithColors(Vector4? background, Vector4? border, Vector4? textColor = null) + => this with { BackgroundColor = background, BorderColor = border, TextColor = textColor }; + + public static ProfileTagDefinition FromText(string text, Vector4? background = null, Vector4? border = null, Vector4? textColor = null) + => new(text, null, false, background, border, textColor); + + public static ProfileTagDefinition FromIcon(uint iconId, Vector4? background = null, Vector4? border = null) + => new(null, $"", true, background, border, null); + + public static ProfileTagDefinition FromIconAndText(uint iconId, string text, Vector4? background = null, Vector4? border = null, Vector4? textColor = null) + => new(text, $" {text}", true, background, border, textColor); + + public static ProfileTagDefinition FromSeString(string payload, Vector4? background = null, Vector4? border = null, Vector4? textColor = null) + => new(null, payload, true, background, border, textColor); +} diff --git a/LightlessSync/UI/Tags/ProfileTagRenderer.cs b/LightlessSync/UI/Tags/ProfileTagRenderer.cs new file mode 100644 index 0000000..67147ee --- /dev/null +++ b/LightlessSync/UI/Tags/ProfileTagRenderer.cs @@ -0,0 +1,226 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ImGuiSeStringRenderer; +using Dalamud.Interface.Textures.TextureWraps; +using Dalamud.Interface.Utility; +using LightlessSync.Utils; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace LightlessSync.UI.Tags; + +internal static class ProfileTagRenderer +{ + public static Vector2 MeasureTag( + ProfileTagDefinition tag, + float scale, + ImGuiStylePtr style, + Vector4 fallbackBackground, + Vector4 fallbackBorder, + uint defaultTextColorU32, + List segmentBuffer, + Func iconResolver, + ILogger? logger) + => RenderTagInternal(tag, Vector2.Zero, scale, default, style, fallbackBackground, fallbackBorder, defaultTextColorU32, segmentBuffer, iconResolver, logger, draw: false); + + public static Vector2 RenderTag( + ProfileTagDefinition tag, + Vector2 screenMin, + float scale, + ImDrawListPtr drawList, + ImGuiStylePtr style, + Vector4 fallbackBackground, + Vector4 fallbackBorder, + uint defaultTextColorU32, + List segmentBuffer, + Func iconResolver, + ILogger? logger) + => RenderTagInternal(tag, screenMin, scale, drawList, style, fallbackBackground, fallbackBorder, defaultTextColorU32, segmentBuffer, iconResolver, logger, draw: true); + + private static Vector2 RenderTagInternal( + ProfileTagDefinition tag, + Vector2 screenMin, + float scale, + ImDrawListPtr drawList, + ImGuiStylePtr style, + Vector4 fallbackBackground, + Vector4 fallbackBorder, + uint defaultTextColorU32, + List segmentBuffer, + Func iconResolver, + ILogger? logger, + bool draw) + { + segmentBuffer.Clear(); + + var padding = new Vector2(10f * scale, 6f * scale); + var rounding = style.FrameRounding > 0f ? style.FrameRounding : 6f * scale; + + var backgroundColor = tag.BackgroundColor ?? fallbackBackground; + var borderColor = tag.BorderColor ?? fallbackBorder; + var textColor = tag.TextColor ?? style.Colors[(int)ImGuiCol.Text]; + var textColorU32 = tag.TextColor.HasValue ? ImGui.ColorConvertFloat4ToU32(tag.TextColor.Value) : defaultTextColorU32; + + string? textContent = tag.Text; + Vector2 textSize = string.IsNullOrWhiteSpace(textContent) ? Vector2.Zero : ImGui.CalcTextSize(textContent); + + var sePayload = tag.SeStringPayload; + bool hasSeString = !string.IsNullOrWhiteSpace(sePayload); + bool useTextureSegments = hasSeString && tag.UseTextureSegments; + bool useSeRenderer = hasSeString && !useTextureSegments; + Vector2 seSize = Vector2.Zero; + List? seSegments = null; + + if (hasSeString) + { + if (useSeRenderer) + { + try + { + var drawParams = new SeStringDrawParams + { + TargetDrawList = draw ? drawList : default, + ScreenOffset = draw ? screenMin + padding : Vector2.Zero, + WrapWidth = float.MaxValue + }; + + var measure = ImGuiHelpers.CompileSeStringWrapped(sePayload!, drawParams); + seSize = measure.Size; + if (seSize.Y <= 0f) + seSize.Y = ImGui.GetTextLineHeight(); + + textContent = null; + textSize = Vector2.Zero; + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Failed to compile SeString payload '{Payload}' for profile tag", sePayload); + useSeRenderer = false; + } + } + + if (!useSeRenderer && useTextureSegments) + { + segmentBuffer.Clear(); + if (SeStringUtils.TryResolveSegments(sePayload!, scale, iconResolver, segmentBuffer, out seSize) && segmentBuffer.Count > 0) + { + seSegments = segmentBuffer; + textContent = null; + textSize = Vector2.Zero; + } + else + { + segmentBuffer.Clear(); + var fallback = SeStringUtils.StripMarkup(sePayload!); + if (!string.IsNullOrWhiteSpace(fallback)) + { + textContent = fallback; + textSize = ImGui.CalcTextSize(fallback); + } + } + } + else if (!useSeRenderer && string.IsNullOrWhiteSpace(textContent)) + { + var fallback = SeStringUtils.StripMarkup(sePayload!); + if (!string.IsNullOrWhiteSpace(fallback)) + { + textContent = fallback; + textSize = ImGui.CalcTextSize(fallback); + } + } + } + + bool drewSeString = useSeRenderer || seSegments is { Count: > 0 }; + var contentHeight = drewSeString ? seSize.Y : textSize.Y; + if (contentHeight <= 0f) + contentHeight = ImGui.GetTextLineHeight(); + + var contentWidth = drewSeString ? seSize.X : textSize.X; + if (contentWidth <= 0f) + contentWidth = textSize.X; + if (contentWidth <= 0f) + contentWidth = 40f * scale; + + var tagSize = new Vector2(contentWidth + padding.X * 2f, contentHeight + padding.Y * 2f); + + if (!draw) + { + if (seSegments is not null) + seSegments.Clear(); + return tagSize; + } + + var rectMin = screenMin; + var rectMax = rectMin + tagSize; + drawList.AddRectFilled(rectMin, rectMax, ImGui.ColorConvertFloat4ToU32(backgroundColor), rounding); + drawList.AddRect(rectMin, rectMax, ImGui.ColorConvertFloat4ToU32(borderColor), rounding); + + var contentStart = rectMin + padding; + var verticalOffset = (tagSize.Y - padding.Y * 2f - contentHeight) * 0.5f; + var basePos = new Vector2(contentStart.X, contentStart.Y + MathF.Max(verticalOffset, 0f)); + + if (useSeRenderer && sePayload is { Length: > 0 }) + { + var drawParams = new SeStringDrawParams + { + TargetDrawList = drawList, + ScreenOffset = basePos, + WrapWidth = float.MaxValue + }; + + try + { + ImGuiHelpers.CompileSeStringWrapped(sePayload!, drawParams); + } + catch (Exception ex) + { + logger?.LogDebug(ex, "Failed to draw SeString payload '{Payload}' for profile tag", sePayload); + var fallback = !string.IsNullOrWhiteSpace(textContent) ? textContent : SeStringUtils.StripMarkup(sePayload!); + if (!string.IsNullOrWhiteSpace(fallback)) + drawList.AddText(basePos, textColorU32, fallback); + } + } + else if (seSegments is { Count: > 0 }) + { + var segmentX = basePos.X; + foreach (var segment in seSegments) + { + var segmentPos = new Vector2(segmentX, basePos.Y + (contentHeight - segment.Size.Y) * 0.5f); + switch (segment.Type) + { + case SeStringUtils.SeStringSegmentType.Icon: + if (segment.Texture != null) + { + drawList.AddImage(segment.Texture.Handle, segmentPos, segmentPos + segment.Size); + } + else if (!string.IsNullOrEmpty(segment.Text)) + { + drawList.AddText(segmentPos, textColorU32, segment.Text); + } + break; + case SeStringUtils.SeStringSegmentType.Text: + var colorU32 = segment.Color.HasValue + ? ImGui.ColorConvertFloat4ToU32(segment.Color.Value) + : textColorU32; + drawList.AddText(segmentPos, colorU32, segment.Text ?? string.Empty); + break; + } + + segmentX += segment.Size.X; + } + + seSegments.Clear(); + } + else if (!string.IsNullOrWhiteSpace(textContent)) + { + drawList.AddText(basePos, textColorU32, textContent); + } + else + { + drawList.AddText(basePos, textColorU32, string.Empty); + } + + return tagSize; + } +} diff --git a/LightlessSync/UI/Tags/ProfileTagService.cs b/LightlessSync/UI/Tags/ProfileTagService.cs new file mode 100644 index 0000000..14b1a45 --- /dev/null +++ b/LightlessSync/UI/Tags/ProfileTagService.cs @@ -0,0 +1,131 @@ +using LightlessSync.UI; +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace LightlessSync.UI.Tags; + +/// +/// Library of tags. That's it. +/// +public sealed class ProfileTagService +{ + private static readonly IReadOnlyDictionary TagLibrary = CreateTagLibrary(); + + public IReadOnlyDictionary GetTagLibrary() + => TagLibrary; + + public IReadOnlyList ResolveTags(IReadOnlyList? tagIds) + { + if (tagIds is null || tagIds.Count == 0) + return Array.Empty(); + + var result = new List(tagIds.Count); + foreach (var id in tagIds) + { + if (TagLibrary.TryGetValue(id, out var tag)) + result.Add(tag); + } + + return result; + } + + public bool TryGetDefinition(int tagId, out ProfileTagDefinition definition) + => TagLibrary.TryGetValue(tagId, out definition); + + private static IReadOnlyDictionary CreateTagLibrary() + { + var dictionary = new Dictionary + { + [(int)ProfileTags.SFW] = ProfileTagDefinition.FromIconAndText( + 230419, + "SFW", + background: new Vector4(0.16f, 0.24f, 0.18f, 0.95f), + border: new Vector4(0.32f, 0.52f, 0.34f, 0.85f), + textColor: new Vector4(0.78f, 0.94f, 0.80f, 1f)), + + [(int)ProfileTags.NSFW] = ProfileTagDefinition.FromIconAndText( + 230419, + "NSFW", + background: new Vector4(0.32f, 0.18f, 0.22f, 0.95f), + border: new Vector4(0.72f, 0.32f, 0.38f, 0.85f), + textColor: new Vector4(1f, 0.82f, 0.86f, 1f)), + + + [(int)ProfileTags.RP] = ProfileTagDefinition.FromIconAndText( + 61545, + "RP", + background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f), + border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f), + textColor: new Vector4(0.80f, 0.84f, 1f, 1f)), + + [(int)ProfileTags.ERP] = ProfileTagDefinition.FromIconAndText( + 61545, + "ERP", + background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f), + border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f), + textColor: new Vector4(0.80f, 0.84f, 1f, 1f)), + + [(int)ProfileTags.No_RP] = ProfileTagDefinition.FromIconAndText( + 230420, + "No RP", + background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f), + border: new Vector4(0.69f, 0.40f, 0.65f, 0.85f), + textColor: new Vector4(1f, 0.84f, 1f, 1f)), + + [(int)ProfileTags.No_ERP] = ProfileTagDefinition.FromIconAndText( + 230420, + "No ERP", + background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f), + border: new Vector4(0.69f, 0.40f, 0.65f, 0.85f), + textColor: new Vector4(1f, 0.84f, 1f, 1f)), + + + [(int)ProfileTags.Venues] = ProfileTagDefinition.FromIconAndText( + 60756, + "Venues", + background: new Vector4(0.18f, 0.24f, 0.28f, 0.95f), + border: new Vector4(0.33f, 0.55f, 0.63f, 0.85f), + textColor: new Vector4(0.78f, 0.90f, 0.97f, 1f)), + + [(int)ProfileTags.Gpose] = ProfileTagDefinition.FromIconAndText( + 61546, + "GPose", + background: new Vector4(0.18f, 0.18f, 0.26f, 0.95f), + border: new Vector4(0.35f, 0.34f, 0.54f, 0.85f), + textColor: new Vector4(0.80f, 0.82f, 0.96f, 1f)), + + + [(int)ProfileTags.Limsa] = ProfileTagDefinition.FromIconAndText( + 60572, + "Limsa"), + + [(int)ProfileTags.Gridania] = ProfileTagDefinition.FromIconAndText( + 60573, + "Gridania"), + + [(int)ProfileTags.Ul_dah] = ProfileTagDefinition.FromIconAndText( + 60574, + "Ul'dah"), + + + [(int)ProfileTags.WUT] = ProfileTagDefinition.FromIconAndText( + 61397, + "WU/T"), + + + [(int)ProfileTags.PVP] = ProfileTagDefinition.FromIcon(61806), + [(int)ProfileTags.Ultimate] = ProfileTagDefinition.FromIcon(61832), + [(int)ProfileTags.Raids] = ProfileTagDefinition.FromIcon(61802), + [(int)ProfileTags.Roulette] = ProfileTagDefinition.FromIcon(61807), + [(int)ProfileTags.Crafting] = ProfileTagDefinition.FromIcon(61816), + [(int)ProfileTags.Casual] = ProfileTagDefinition.FromIcon(61753), + [(int)ProfileTags.Hardcore] = ProfileTagDefinition.FromIcon(61754), + [(int)ProfileTags.Glamour] = ProfileTagDefinition.FromIcon(61759), + [(int)ProfileTags.Mentor] = ProfileTagDefinition.FromIcon(61760) + + }; + + return dictionary; + } +} diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index b4327c0..cd24118 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -1,3 +1,4 @@ +using System; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; @@ -10,8 +11,12 @@ using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; +using LightlessSync.UI.Models; +using LightlessSync.UI.Style; using LightlessSync.WebAPI; using System.Numerics; +using System.Threading.Tasks; +using System.Linq; namespace LightlessSync.UI; @@ -22,7 +27,6 @@ public class TopTabMenu private readonly LightlessMediator _lightlessMediator; - private readonly PairManager _pairManager; private readonly PairRequestService _pairRequestService; private readonly DalamudUtilService _dalamudUtilService; private readonly HashSet _pendingPairRequestActions = new(StringComparer.Ordinal); @@ -36,11 +40,12 @@ public class TopTabMenu private string _pairToAdd = string.Empty; private SelectedTab _selectedTab = SelectedTab.None; - public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, PairManager pairManager, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) + private PairUiSnapshot? _currentSnapshot; + + public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) { _lightlessMediator = lightlessMediator; _apiController = apiController; - _pairManager = pairManager; _pairRequestService = pairRequestService; _dalamudUtilService = dalamudUtilService; _uiSharedService = uiSharedService; @@ -77,34 +82,46 @@ public class TopTabMenu _selectedTab = value; } } - public void Draw() + + private PairUiSnapshot Snapshot => _currentSnapshot ?? throw new InvalidOperationException("Pair UI snapshot is not available outside of Draw."); + + public void Draw(PairUiSnapshot snapshot) { - var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; - var spacing = ImGui.GetStyle().ItemSpacing; - var buttonX = (availableWidth - (spacing.X * 4)) / 5f; - var buttonY = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Pause).Y; - var buttonSize = new Vector2(buttonX, buttonY); - var drawList = ImGui.GetWindowDrawList(); - var underlineColor = ImGui.GetColorU32(UIColors.Get("LightlessPurpleActive")); // ImGui.GetColorU32(ImGuiCol.Separator); - var btncolor = ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))); - - ImGuiHelpers.ScaledDummy(spacing.Y / 2f); - - using (ImRaii.PushFont(UiBuilder.IconFont)) + _currentSnapshot = snapshot; + try { - var x = ImGui.GetCursorScreenPos(); - if (ImGui.Button(FontAwesomeIcon.User.ToIconString(), buttonSize)) + var availableWidth = ImGui.GetWindowContentRegionMax().X - ImGui.GetWindowContentRegionMin().X; + var spacing = ImGui.GetStyle().ItemSpacing; + var buttonX = (availableWidth - (spacing.X * 5)) / 6f; + var buttonY = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Pause).Y; + var buttonSize = new Vector2(buttonX, buttonY); + const float buttonBorderThickness = 12f; + var buttonRounding = ImGui.GetStyle().FrameRounding; + var drawList = ImGui.GetWindowDrawList(); + var underlineColor = ImGui.GetColorU32(UIColors.Get("LightlessPurpleActive")); // ImGui.GetColorU32(ImGuiCol.Separator); + var btncolor = ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new(0, 0, 0, 0))); + + ImGuiHelpers.ScaledDummy(spacing.Y / 2f); + + using (ImRaii.PushFont(UiBuilder.IconFont)) { - TabSelection = TabSelection == SelectedTab.Individual ? SelectedTab.None : SelectedTab.Individual; + var x = ImGui.GetCursorScreenPos(); + if (ImGui.Button(FontAwesomeIcon.User.ToIconString(), buttonSize)) + { + TabSelection = TabSelection == SelectedTab.Individual ? SelectedTab.None : SelectedTab.Individual; + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + ImGui.SameLine(); + var xAfter = ImGui.GetCursorScreenPos(); + if (TabSelection == SelectedTab.Individual) + drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, + xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, + underlineColor, 2); } - ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Individual) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); - } - UiSharedService.AttachToolTip("Individual Pair Menu"); + UiSharedService.AttachToolTip("Individual Pair Menu"); using (ImRaii.PushFont(UiBuilder.IconFont)) { @@ -113,6 +130,10 @@ public class TopTabMenu { TabSelection = TabSelection == SelectedTab.Syncshell ? SelectedTab.None : SelectedTab.Syncshell; } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } ImGui.SameLine(); var xAfter = ImGui.GetCursorScreenPos(); if (TabSelection == SelectedTab.Syncshell) @@ -122,6 +143,20 @@ public class TopTabMenu } UiSharedService.AttachToolTip("Syncshell Menu"); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Comments.ToIconString(), buttonSize)) + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(ZoneChatUi))); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + } + UiSharedService.AttachToolTip("Zone Chat"); + ImGui.SameLine(); + ImGui.SameLine(); using (ImRaii.PushFont(UiBuilder.IconFont)) { @@ -130,6 +165,10 @@ public class TopTabMenu { TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } ImGui.SameLine(); var xAfter = ImGui.GetCursorScreenPos(); @@ -148,6 +187,10 @@ public class TopTabMenu { TabSelection = TabSelection == SelectedTab.UserConfig ? SelectedTab.None : SelectedTab.UserConfig; } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } ImGui.SameLine(); var xAfter = ImGui.GetCursorScreenPos(); @@ -166,6 +209,10 @@ public class TopTabMenu { _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } ImGui.SameLine(); } UiSharedService.AttachToolTip("Open Lightless Settings"); @@ -196,12 +243,18 @@ public class TopTabMenu if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); + DrawIncomingPairRequests(availableWidth); ImGui.Separator(); DrawFilter(availableWidth, spacing.X); } + finally + { + _currentSnapshot = null; + } + } private void DrawAddPair(float availableXWidth, float spacingX) { @@ -209,7 +262,7 @@ public class TopTabMenu ImGui.SetNextItemWidth(availableXWidth - buttonSize - spacingX); ImGui.InputTextWithHint("##otheruid", "Other players UID/Alias", ref _pairToAdd, 20); ImGui.SameLine(); - var alreadyExisting = _pairManager.DirectPairs.Exists(p => string.Equals(p.UserData.UID, _pairToAdd, StringComparison.Ordinal) || string.Equals(p.UserData.Alias, _pairToAdd, StringComparison.Ordinal)); + var alreadyExisting = Snapshot.DirectPairs.Any(p => string.Equals(p.UserData.UID, _pairToAdd, StringComparison.Ordinal) || string.Equals(p.UserData.Alias, _pairToAdd, StringComparison.Ordinal)); using (ImRaii.Disabled(alreadyExisting || string.IsNullOrEmpty(_pairToAdd))) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.UserPlus, "Add")) @@ -431,12 +484,23 @@ public class TopTabMenu { Filter = filter; } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, 10, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); + } ImGui.SameLine(); - using var disabled = ImRaii.Disabled(string.IsNullOrEmpty(Filter)); + var disableClear = string.IsNullOrEmpty(Filter); + using var disabled = ImRaii.Disabled(disableClear); + var clearHovered = false; if (_uiSharedService.IconTextButton(FontAwesomeIcon.Ban, "Clear")) { Filter = string.Empty; } + clearHovered = ImGui.IsItemHovered(); + if (!disableClear && clearHovered) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, 10, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); + } } private void DrawGlobalIndividualButtons(float availableXWidth, float spacingX) @@ -666,7 +730,7 @@ public class TopTabMenu if (ImGui.Button(FontAwesomeIcon.Check.ToIconString(), buttonSize)) { _ = GlobalControlCountdown(10); - var bulkSyncshells = _pairManager.GroupPairs.Keys.OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) + var bulkSyncshells = Snapshot.GroupPairs.Keys.OrderBy(g => g.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Group.GID, g => { var perm = g.GroupUserPermissions; @@ -691,7 +755,8 @@ public class TopTabMenu { var buttonX = (availableWidth - (spacingX)) / 2f; - using (ImRaii.Disabled(_pairManager.GroupPairs.Select(k => k.Key).Distinct() + using (ImRaii.Disabled(Snapshot.GroupPairs.Keys + .Distinct() .Count(g => string.Equals(g.OwnerUID, _apiController.UID, StringComparison.Ordinal)) >= _apiController.ServerInfo.MaxGroupsCreatedByUser)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Create new Syncshell", buttonX)) @@ -701,7 +766,7 @@ public class TopTabMenu ImGui.SameLine(); } - using (ImRaii.Disabled(_pairManager.GroupPairs.Select(k => k.Key).Distinct().Count() >= _apiController.ServerInfo.MaxGroupsJoinedByUser)) + using (ImRaii.Disabled(Snapshot.GroupPairs.Keys.Distinct().Count() >= _apiController.ServerInfo.MaxGroupsJoinedByUser)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Users, "Join existing Syncshell", buttonX)) { @@ -770,7 +835,7 @@ public class TopTabMenu if (_uiSharedService.IconTextButton(enableIcon, enableText, null, true)) { _ = GlobalControlCountdown(10); - var bulkIndividualPairs = _pairManager.PairsWithGroups.Keys + var bulkIndividualPairs = Snapshot.PairsWithGroups.Keys .Where(g => g.IndividualPairStatus == IndividualPairStatus.Bidirectional) .ToDictionary(g => g.UserPair.User.UID, g => { @@ -784,7 +849,7 @@ public class TopTabMenu if (_uiSharedService.IconTextButton(disableIcon, disableText, null, true)) { _ = GlobalControlCountdown(10); - var bulkIndividualPairs = _pairManager.PairsWithGroups.Keys + var bulkIndividualPairs = Snapshot.PairsWithGroups.Keys .Where(g => g.IndividualPairStatus == IndividualPairStatus.Bidirectional) .ToDictionary(g => g.UserPair.User.UID, g => { @@ -808,7 +873,7 @@ public class TopTabMenu if (_uiSharedService.IconTextButton(enableIcon, enableText, null, true)) { _ = GlobalControlCountdown(10); - var bulkSyncshells = _pairManager.GroupPairs.Keys + var bulkSyncshells = Snapshot.GroupPairs.Keys .OrderBy(u => u.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Group.GID, g => { @@ -822,7 +887,7 @@ public class TopTabMenu if (_uiSharedService.IconTextButton(disableIcon, disableText, null, true)) { _ = GlobalControlCountdown(10); - var bulkSyncshells = _pairManager.GroupPairs.Keys + var bulkSyncshells = Snapshot.GroupPairs.Keys .OrderBy(u => u.GroupAliasOrGID, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Group.GID, g => { diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 3c1eabd..98551f3 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -15,7 +15,9 @@ namespace LightlessSync.UI { "FullBlack", "#000000" }, { "LightlessBlue", "#a6c2ff" }, { "LightlessYellow", "#ffe97a" }, + { "LightlessYellow2", "#cfbd63" }, { "LightlessGreen", "#7cd68a" }, + { "LightlessGreenDefault", "#468a50" }, { "LightlessOrange", "#ffb366" }, { "PairBlue", "#88a2db" }, { "DimRed", "#d44444" }, @@ -25,6 +27,9 @@ namespace LightlessSync.UI { "Lightfinder", "#ad8af5" }, { "LightfinderEdge", "#000000" }, + + { "ProfileBodyGradientTop", "#2f283fff" }, + { "ProfileBodyGradientBottom", "#372d4d00" }, }; private static LightlessConfigService? _configService; diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index eb3acce..1d1c6b0 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -4,6 +4,7 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.GameFonts; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; @@ -400,10 +401,21 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase public static bool ShiftPressed() => (GetKeyState(0xA1) & 0x8000) != 0 || (GetKeyState(0xA0) & 0x8000) != 0; - public static void TextWrapped(string text, float wrapPos = 0) + public static void TextWrapped(string text, float wrapPos = 0, Vector4? color = null) { ImGui.PushTextWrapPos(wrapPos); + if (color.HasValue) + { + ImGui.PushStyleColor(ImGuiCol.Text, color.Value); + } + ImGui.TextUnformatted(text); + + if (color.HasValue) + { + ImGui.PopStyleColor(); + } + ImGui.PopTextWrapPos(); } @@ -519,8 +531,9 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase bool changed = ImGui.Checkbox(label, ref value); + var boxSize = ImGui.GetFrameHeight(); var min = pos; - var max = ImGui.GetItemRectMax(); + var max = new Vector2(pos.X + boxSize, pos.Y + boxSize); var col = ImGui.GetColorU32(borderColor ?? ImGuiColors.DalamudGrey); ImGui.GetWindowDrawList().AddRect(min, max, col, rounding, ImDrawFlags.None, borderThickness); @@ -1220,6 +1233,100 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return _textureProvider.CreateFromImageAsync(imageData).Result; } + private static readonly (bool ItemHq, bool HiRes)[] IconLookupOrders = + [ + (false, true), + (true, true), + (false, false), + (true, false) + ]; + + public bool TryGetIcon(uint iconId, out IDalamudTextureWrap? wrap) + { + foreach (var (itemHq, hiRes) in IconLookupOrders) + { + if (TryGetIconWithLookup(iconId, itemHq, hiRes, out wrap)) + return true; + } + + foreach (var (itemHq, hiRes) in IconLookupOrders) + { + if (!_textureProvider.TryGetIconPath(new GameIconLookup(iconId, itemHq, hiRes), out var path) || string.IsNullOrEmpty(path)) + continue; + + try + { + var reference = _textureProvider.GetFromGame(path); + if (reference.TryGetWrap(out var texture, out _)) + { + wrap = texture; + return true; + } + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to load icon {IconId} from path {Path}", iconId, path); + } + } + + foreach (var hiRes in new[] { true, false }) + { + var manualPath = BuildIconPath(iconId, hiRes); + if (TryLoadTextureFromPath(manualPath, iconId, out wrap)) + return true; + } + + wrap = null; + return false; + } + + private bool TryLoadTextureFromPath(string path, uint iconId, out IDalamudTextureWrap? wrap) + { + try + { + var reference = _textureProvider.GetFromGame(path); + if (reference.TryGetWrap(out var texture, out _)) + { + wrap = texture; + return true; + } + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to load icon {IconId} from manual path {Path}", iconId, path); + } + + wrap = null; + return false; + } + + private static string BuildIconPath(uint iconId, bool hiRes) + { + var folder = iconId - iconId % 1000; + var basePath = $"ui/icon/{folder:000000}/{iconId:000000}"; + return hiRes ? $"{basePath}_hr1.tex" : $"{basePath}.tex"; + } + + private bool TryGetIconWithLookup(uint iconId, bool itemHq, bool hiRes, out IDalamudTextureWrap? wrap) + { + try + { + var icon = _textureProvider.GetFromGameIcon(new GameIconLookup(iconId, itemHq, hiRes)); + if (icon.TryGetWrap(out var texture, out _)) + { + wrap = texture; + return true; + } + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Failed to load icon {IconId} (HQ:{ItemHq}, HR:{HiRes})", iconId, itemHq, hiRes); + } + + wrap = null; + return false; + } + public void LoadLocalization(string languageCode) { _localization.SetupWithLangCode(languageCode); @@ -1285,13 +1392,24 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase num++; } - ImGui.PushID(text); + string displayText = text; + string idText = text; + int idSeparatorIndex = text.IndexOf("##", StringComparison.Ordinal); + if (idSeparatorIndex >= 0) + { + displayText = text[..idSeparatorIndex]; + idText = text[(idSeparatorIndex + 2)..]; + if (string.IsNullOrEmpty(idText)) + idText = displayText; + } + + ImGui.PushID(idText); Vector2 vector; using (IconFont.Push()) vector = ImGui.CalcTextSize(icon.ToIconString()); - Vector2 vector2 = ImGui.CalcTextSize(text); + Vector2 vector2 = ImGui.CalcTextSize(displayText); ImDrawListPtr windowDrawList = ImGui.GetWindowDrawList(); Vector2 cursorScreenPos = ImGui.GetCursorScreenPos(); float num2 = 3f * ImGuiHelpers.GlobalScale; @@ -1316,7 +1434,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase windowDrawList.AddText(pos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString()); Vector2 pos2 = new Vector2(pos.X + vector.X + num2, cursorScreenPos.Y + ImGui.GetStyle().FramePadding.Y); - windowDrawList.AddText(pos2, ImGui.GetColorU32(ImGuiCol.Text), text); + windowDrawList.AddText(pos2, ImGui.GetColorU32(ImGuiCol.Text), displayText); ImGui.PopID(); if (num > 0) { diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs new file mode 100644 index 0000000..d8ac877 --- /dev/null +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -0,0 +1,1101 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Threading.Tasks; +using LightlessSync.API.Data; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Dto.Chat; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services; +using LightlessSync.Services.Chat; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.Utils; +using LightlessSync.WebAPI; +using LightlessSync.WebAPI.SignalR.Utils; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.UI; + +public sealed class ZoneChatUi : WindowMediatorSubscriberBase +{ + private const string ChatDisabledStatus = "Chat services disabled"; + private const string SettingsPopupId = "zone_chat_settings_popup"; + private const float DefaultWindowOpacity = .97f; + private const float MinWindowOpacity = 0.05f; + private const float MaxWindowOpacity = 1f; + + private readonly UiSharedService _uiSharedService; + private readonly ZoneChatService _zoneChatService; + private readonly PairUiService _pairUiService; + private readonly LightlessProfileManager _profileManager; + private readonly ApiController _apiController; + private readonly ChatConfigService _chatConfigService; + private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); + private readonly ImGuiWindowFlags _unpinnedWindowFlags; + private float _currentWindowOpacity = DefaultWindowOpacity; + private bool _isWindowPinned; + private bool _showRulesOverlay = true; + + private string? _selectedChannelKey; + private bool _scrollToBottom = true; + private float? _pendingChannelScroll; + private float _channelScroll; + private float _channelScrollMax; + + public ZoneChatUi( + ILogger logger, + LightlessMediator mediator, + UiSharedService uiSharedService, + ZoneChatService zoneChatService, + PairUiService pairUiService, + LightlessProfileManager profileManager, + ChatConfigService chatConfigService, + ApiController apiController, + PerformanceCollectorService performanceCollectorService) + : base(logger, mediator, "Zone Chat", performanceCollectorService) + { + _uiSharedService = uiSharedService; + _zoneChatService = zoneChatService; + _pairUiService = pairUiService; + _profileManager = profileManager; + _chatConfigService = chatConfigService; + _apiController = apiController; + _isWindowPinned = _chatConfigService.Current.IsWindowPinned; + _showRulesOverlay = _chatConfigService.Current.ShowRulesOverlayOnOpen; + if (_chatConfigService.Current.AutoOpenChatOnPluginLoad) + { + IsOpen = true; + } + _unpinnedWindowFlags = Flags; + RefreshWindowFlags(); + Size = new Vector2(450, 420) * ImGuiHelpers.GlobalScale; + SizeCondition = ImGuiCond.FirstUseEver; + SizeConstraints = new() + { + MinimumSize = new Vector2(320f, 260f) * ImGuiHelpers.GlobalScale, + MaximumSize = new Vector2(900f, 900f) * ImGuiHelpers.GlobalScale + }; + + Mediator.Subscribe(this, OnChatChannelMessageAdded); + Mediator.Subscribe(this, msg => + { + if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, msg.ChannelKey, StringComparison.Ordinal)) + { + _scrollToBottom = true; + } + }); + Mediator.Subscribe(this, _ => _scrollToBottom = true); + } + + public override void PreDraw() + { + RefreshWindowFlags(); + base.PreDraw(); + _currentWindowOpacity = Math.Clamp(_chatConfigService.Current.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + ImGui.SetNextWindowBgAlpha(_currentWindowOpacity); + } + + protected override void DrawInternal() + { + var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; + childBgColor.W *= _currentWindowOpacity; + using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor); + DrawConnectionControls(); + + var channels = _zoneChatService.GetChannelsSnapshot(); + + if (channels.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped("No chat channels available."); + ImGui.PopStyleColor(); + return; + } + + EnsureSelectedChannel(channels); + CleanupDrafts(channels); + + DrawChannelButtons(channels); + + if (_selectedChannelKey is null) + return; + + var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); + if (activeChannel.Equals(default(ChatChannelSnapshot))) + { + activeChannel = channels[0]; + _selectedChannelKey = activeChannel.Key; + } + + _zoneChatService.SetActiveChannel(activeChannel.Key); + + DrawHeader(activeChannel); + ImGui.Separator(); + DrawMessageArea(activeChannel, _currentWindowOpacity); + ImGui.Separator(); + DrawInput(activeChannel); + + if (_showRulesOverlay) + { + DrawRulesOverlay(); + } + } + + private void DrawHeader(ChatChannelSnapshot channel) + { + var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; + Vector4 color; + + if (!channel.IsConnected) + { + color = UIColors.Get("DimRed"); + } + else if (!channel.IsAvailable) + { + color = ImGuiColors.DalamudGrey3; + } + else + { + color = channel.Type == ChatChannelType.Zone ? UIColors.Get("LightlessPurple") : UIColors.Get("LightlessBlue"); + } + + ImGui.TextColored(color, $"{prefix}: {channel.DisplayName}"); + + if (channel.Type == ChatChannelType.Zone && channel.Descriptor.WorldId != 0) + { + ImGui.SameLine(); + ImGui.TextUnformatted($"World #{channel.Descriptor.WorldId}"); + } + + var showInlineDisabled = string.Equals(channel.StatusText, ChatDisabledStatus, StringComparison.OrdinalIgnoreCase); + if (showInlineDisabled) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"| {channel.StatusText}"); + ImGui.PopStyleColor(); + } + else if (!string.IsNullOrEmpty(channel.StatusText)) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped(channel.StatusText); + ImGui.PopStyleColor(); + } + } + + private void DrawMessageArea(ChatChannelSnapshot channel, float windowOpacity) + { + var available = ImGui.GetContentRegionAvail(); + var inputHeight = ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y; + var height = Math.Max(100f * ImGuiHelpers.GlobalScale, available.Y - inputHeight); + + using var child = ImRaii.Child($"chat_messages_{channel.Key}", new Vector2(-1, height), false); + if (!child) + return; + + var drawList = ImGui.GetWindowDrawList(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var gradientBottom = UIColors.Get("LightlessPurple"); + var bottomAlpha = 0.12f * windowOpacity; + var bottomColorVec = new Vector4(gradientBottom.X, gradientBottom.Y, gradientBottom.Z, bottomAlpha); + var topColorVec = new Vector4(gradientBottom.X, gradientBottom.Y, gradientBottom.Z, 0.0f); + var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec); + var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec); + drawList.AddRectFilledMultiColor( + windowPos, + windowPos + windowSize, + topColor, + topColor, + bottomColor, + bottomColor); + + var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps; + + if (channel.Messages.Count == 0) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped("Chat history will appear here when available."); + ImGui.PopStyleColor(); + } + else + { + for (var i = 0; i < channel.Messages.Count; i++) + { + var message = channel.Messages[i]; + 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.PushID(i); + ImGui.PushStyleColor(ImGuiCol.Text, color); + ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {message.Payload.Message}"); + ImGui.PopStyleColor(); + + if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + { + foreach (var action in GetContextMenuActions(channel, message)) + { + if (ImGui.MenuItem(action.Label, string.Empty, false, action.IsEnabled)) + { + action.Execute(); + } + } + + ImGui.EndPopup(); + } + + ImGui.PopID(); + } + } + + if (_scrollToBottom) + { + ImGui.SetScrollHereY(1f); + _scrollToBottom = false; + } + } + + private void DrawInput(ChatChannelSnapshot channel) + { + const int MaxMessageLength = ZoneChatService.MaxOutgoingLength; + var canSend = channel.IsConnected && channel.IsAvailable; + _draftMessages.TryGetValue(channel.Key, out var draft); + draft ??= string.Empty; + + using (ImRaii.Disabled(!canSend)) + { + var style = ImGui.GetStyle(); + var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale; + var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X; + var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f; + + ImGui.SetNextItemWidth(-reservedWidth); + var inputId = $"##chat-input-{channel.Key}"; + var send = ImGui.InputText(inputId, ref draft, MaxMessageLength, ImGuiInputTextFlags.EnterReturnsTrue); + _draftMessages[channel.Key] = draft; + + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"{draft.Length}/{MaxMessageLength}"); + ImGui.PopStyleColor(); + + ImGui.SameLine(); + var buttonScreenPos = ImGui.GetCursorScreenPos(); + var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X; + var desiredButtonX = rightEdgeScreen - sendButtonWidth; + var minButtonX = buttonScreenPos.X + style.ItemSpacing.X; + var finalButtonX = MathF.Max(minButtonX, desiredButtonX); + ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y)); + var sendColor = UIColors.Get("LightlessPurpleDefault"); + var sendHovered = UIColors.Get("LightlessPurple"); + var sendActive = UIColors.Get("LightlessPurpleActive"); + ImGui.PushStyleColor(ImGuiCol.Button, sendColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, sendHovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, sendActive); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6f * ImGuiHelpers.GlobalScale); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, "Send", 100f * ImGuiHelpers.GlobalScale, center: true)) + { + send = true; + } + ImGui.PopStyleVar(); + ImGui.PopStyleColor(3); + + if (send && TrySendDraft(channel, draft)) + { + _draftMessages[channel.Key] = string.Empty; + _scrollToBottom = true; + } + } + } + + private void DrawRulesOverlay() + { + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var parentContentMin = ImGui.GetWindowContentRegionMin(); + var parentContentMax = ImGui.GetWindowContentRegionMax(); + var overlayPos = windowPos + parentContentMin; + var overlaySize = parentContentMax - parentContentMin; + + if (overlaySize.X <= 0f || overlaySize.Y <= 0f) + { + overlayPos = windowPos; + overlaySize = windowSize; + } + + ImGui.SetNextWindowFocus(); + ImGui.SetNextWindowPos(overlayPos); + ImGui.SetNextWindowSize(overlaySize); + ImGui.SetNextWindowBgAlpha(0.86f); + ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 6f * ImGuiHelpers.GlobalScale); + ImGui.PushStyleColor(ImGuiCol.Border, Vector4.Zero); + + var overlayFlags = ImGuiWindowFlags.NoDecoration + | ImGuiWindowFlags.NoMove + | ImGuiWindowFlags.NoScrollbar + | ImGuiWindowFlags.NoSavedSettings; + + var overlayOpen = true; + if (ImGui.Begin("##zone_chat_rules_overlay", ref overlayOpen, overlayFlags)) + { + var contentMin = ImGui.GetWindowContentRegionMin(); + var contentMax = ImGui.GetWindowContentRegionMax(); + var contentWidth = contentMax.X - contentMin.X; + var title = "Chat Rules"; + var titleWidth = ImGui.CalcTextSize(title).X; + + ImGui.SetCursorPosX(contentMin.X + Math.Max(0f, (contentWidth - titleWidth) * 0.5f)); + ImGui.TextUnformatted(title); + + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + var style = ImGui.GetStyle(); + var buttonWidth = 180f * ImGuiHelpers.GlobalScale; + var buttonHeight = ImGui.GetFrameHeight(); + var buttonSpacing = Math.Max(0f, style.ItemSpacing.Y); + var buttonTopY = Math.Max(contentMin.Y, contentMax.Y - buttonHeight); + var childHeight = Math.Max(0f, buttonTopY - buttonSpacing - ImGui.GetCursorPosY()); + + using (var child = ImRaii.Child("zone_chat_rules_overlay_scroll", new Vector2(-1f, childHeight), false)) + { + if (child) + { + var childContentMin = ImGui.GetWindowContentRegionMin(); + var childContentMax = ImGui.GetWindowContentRegionMax(); + var childContentWidth = childContentMax.X - childContentMin.X; + + ImGui.PushTextWrapPos(childContentMin.X + childContentWidth); + + _uiSharedService.MediumText("Basic Chat Rules", UIColors.Get("LightlessBlue")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Do "), + new SeStringUtils.RichTextEntry("NOT share", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" confidential, personal, or account information-yours or anyone else's", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Respect ALL participants; "), + new SeStringUtils.RichTextEntry("no harassment, hate speech, or personal attacks", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("AVOID ", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry("disruptive behaviors such as "), + new SeStringUtils.RichTextEntry("spamming or flooding", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Absolutely "), + new SeStringUtils.RichTextEntry("NO discussion, sharing, or solicitation of illegal content or activities;", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(".")); + + ImGui.Dummy(new Vector2(5)); + + ImGui.Separator(); + _uiSharedService.MediumText("Zone Chat Rules", UIColors.Get("LightlessGreen")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("NO ADVERTISEMENTS", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" whatsoever for any kind of "), + new SeStringUtils.RichTextEntry("services, venues, clubs, marketboard deals, or similar offerings", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("Mature", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" or "), + new SeStringUtils.RichTextEntry("NSFW", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" content "), + new SeStringUtils.RichTextEntry("(including suggestive emotes, explicit innuendo, or roleplay (including requests) )", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(" is"), + new SeStringUtils.RichTextEntry(" strictly prohibited ", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry("in Zone Chat.")); + + ImGui.Dummy(new Vector2(5)); + + ImGui.Separator(); + _uiSharedService.MediumText("Syncshell Chat Rules", UIColors.Get("LightlessYellow")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("Syncshell chats are self-moderated (their own set rules) by it's owner and appointed moderators. If they fail to enforce chat rules within their syncshell, the owner (and its moderators) may face punishment.")); + + ImGui.Dummy(new Vector2(5)); + + ImGui.Separator(); + _uiSharedService.MediumText("Reporting & Punishments", UIColors.Get("LightlessBlue")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), + new SeStringUtils.RichTextEntry("Report rule-breakers by right clicking on the sent message and clicking report or via the mod-mail channel on the Discord."), + new SeStringUtils.RichTextEntry(" (False reports may be punished.) ", UIColors.Get("DimRed"), true)); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), + new SeStringUtils.RichTextEntry("Punishments scale from a permanent chat ban up to a full Lightless account ban."), + new SeStringUtils.RichTextEntry(" (Appeals are NOT possible.) ", UIColors.Get("DimRed"), true)); + + ImGui.PopTextWrapPos(); + } + } + + var spacingStartY = Math.Max(ImGui.GetCursorPosY(), buttonTopY - buttonSpacing); + ImGui.SetCursorPosY(spacingStartY); + + if (buttonSpacing > 0f) + { + var actualSpacing = Math.Max(0f, buttonTopY - spacingStartY); + if (actualSpacing > 0f) + { + ImGui.Dummy(new Vector2(0f, actualSpacing)); + } + } + + ImGui.SetCursorPosY(buttonTopY); + + var buttonX = contentMin.X + Math.Max(0f, (contentWidth - buttonWidth) * 0.5f); + ImGui.SetCursorPosX(buttonX); + + if (ImGui.Button("I Understand", new Vector2(buttonWidth, buttonHeight))) + { + _showRulesOverlay = false; + } + + if (!overlayOpen) + { + _showRulesOverlay = false; + } + } + + ImGui.End(); + ImGui.PopStyleColor(); + ImGui.PopStyleVar(); + } + + private bool TrySendDraft(ChatChannelSnapshot channel, string draft) + { + var trimmed = draft.Trim(); + if (trimmed.Length == 0) + return false; + + bool succeeded; + try + { + succeeded = _zoneChatService.SendMessageAsync(channel.Descriptor, trimmed).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send chat message"); + succeeded = false; + } + + return succeeded; + } + + private IEnumerable GetContextMenuActions(ChatChannelSnapshot channel, ChatMessageEntry message) + { + if (TryCreateCopyMessageAction(message, out var copyAction)) + { + yield return copyAction; + } + + if (TryCreateViewProfileAction(channel, message, out var viewProfile)) + { + yield return viewProfile; + } + } + + private bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) + { + var text = message.Payload.Message; + if (string.IsNullOrEmpty(text)) + { + action = default; + return false; + } + + action = new ChatMessageContextAction( + "Copy Message", + true, + () => ImGui.SetClipboardText(text)); + return true; + } + + private bool TryCreateViewProfileAction(ChatChannelSnapshot channel, ChatMessageEntry message, out ChatMessageContextAction action) + { + action = default; + switch (channel.Type) + { + case ChatChannelType.Group: + { + var user = message.Payload.Sender.User; + if (user?.UID is not { Length: > 0 }) + return false; + + var snapshot = _pairUiService.GetSnapshot(); + if (snapshot.PairsByUid.TryGetValue(user.UID, out var pair) && pair is not null) + { + action = new ChatMessageContextAction( + "View Profile", + true, + () => Mediator.Publish(new ProfileOpenStandaloneMessage(pair))); + return true; + } + + action = new ChatMessageContextAction( + "View Profile", + true, + () => RunContextAction(() => OpenStandardProfileAsync(user))); + return true; + + } + + case ChatChannelType.Zone: + if (!message.Payload.Sender.CanResolveProfile) + return false; + + if (string.IsNullOrEmpty(message.Payload.Sender.Token)) + return false; + + action = new ChatMessageContextAction( + "View Profile", + true, + () => RunContextAction(() => OpenZoneParticipantProfileAsync(channel.Descriptor, message.Payload.Sender.Token))); + return true; + + default: + return false; + } + } + + private Task OpenStandardProfileAsync(UserData user) + { + _profileManager.GetLightlessProfile(user); + _profileManager.GetLightlessUserProfile(user); + Mediator.Publish(new OpenUserProfileMessage(user)); + return Task.CompletedTask; + } + + private void RunContextAction(Func action) + { + _ = Task.Run(async () => + { + try + { + await action().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Chat context action failed"); + Mediator.Publish(new NotificationMessage("Zone Chat", "Action failed to complete.", NotificationType.Error, TimeSpan.FromSeconds(3))); + } + }); + } + + private async Task OpenZoneParticipantProfileAsync(ChatChannelDescriptor descriptor, string token) + { + var result = await _zoneChatService.ResolveParticipantAsync(descriptor, token).ConfigureAwait(false); + if (result is null) + { + Mediator.Publish(new NotificationMessage("Zone Chat", "Participant is no longer available.", NotificationType.Warning, TimeSpan.FromSeconds(3))); + return; + } + + var resolved = result.Value; + var hashedCid = resolved.Sender.HashedCid; + if (string.IsNullOrEmpty(hashedCid)) + { + Mediator.Publish(new NotificationMessage("Zone Chat", "This participant remains anonymous.", NotificationType.Warning, TimeSpan.FromSeconds(3))); + return; + } + + await OpenLightfinderProfileInternalAsync(hashedCid).ConfigureAwait(false); + } + + private async Task OpenLightfinderProfileInternalAsync(string hashedCid) + { + var profile = await _profileManager.GetLightfinderProfileAsync(hashedCid).ConfigureAwait(false); + if (profile is null) + { + Mediator.Publish(new NotificationMessage("Zone Chat", "Unable to load Lightfinder profile information.", NotificationType.Warning, TimeSpan.FromSeconds(3))); + return; + } + + var sanitizedUser = profile.Value.User with + { + UID = "Lightfinder User", + Alias = "Lightfinder User" + }; + + Mediator.Publish(new OpenLightfinderProfileMessage(sanitizedUser, profile.Value.ProfileData, hashedCid)); + } + + private void OnChatChannelMessageAdded(ChatChannelMessageAdded message) + { + if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) + { + _scrollToBottom = true; + } + } + + private void EnsureSelectedChannel(IReadOnlyList channels) + { + if (_selectedChannelKey is not null && channels.Any(channel => channel.Key == _selectedChannelKey)) + return; + + _selectedChannelKey = channels.Count > 0 ? channels[0].Key : null; + if (_selectedChannelKey is not null) + { + _zoneChatService.SetActiveChannel(_selectedChannelKey); + _scrollToBottom = true; + } + } + + private void CleanupDrafts(IReadOnlyList channels) + { + var existingKeys = new HashSet(channels.Select(c => c.Key), StringComparer.Ordinal); + foreach (var key in _draftMessages.Keys.ToList()) + { + if (!existingKeys.Contains(key)) + { + _draftMessages.Remove(key); + } + } + } + + private void DrawConnectionControls() + { + var hubState = _apiController.ServerState; + var chatEnabled = _zoneChatService.IsChatEnabled; + var chatConnected = _zoneChatService.IsChatConnected; + var buttonLabel = chatEnabled ? "Disable Chat" : "Enable Chat"; + var style = ImGui.GetStyle(); + var cursorStart = ImGui.GetCursorPos(); + var contentRightX = cursorStart.X + ImGui.GetContentRegionAvail().X; + var rulesButtonWidth = 90f * ImGuiHelpers.GlobalScale; + + using (ImRaii.Group()) + { + if (ImGui.Button(buttonLabel, new Vector2(130f * ImGuiHelpers.GlobalScale, 0f))) + { + ToggleChatConnection(chatEnabled); + } + + ImGui.SameLine(); + var chatStatusText = chatEnabled + ? (chatConnected ? "Chat: Connected" : "Chat: Waiting") + : "Chat: Disabled"; + var statusColor = chatEnabled + ? (chatConnected ? UIColors.Get("LightlessGreen") : UIColors.Get("LightlessYellow")) + : ImGuiColors.DalamudGrey3; + ImGui.PushStyleColor(ImGuiCol.Text, statusColor); + ImGui.TextUnformatted(chatStatusText); + ImGui.PopStyleColor(); + + if (!string.IsNullOrWhiteSpace(_apiController.AuthFailureMessage) && hubState == ServerState.Unauthorized) + { + ImGui.SameLine(); + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.TextUnformatted(_apiController.AuthFailureMessage); + ImGui.PopStyleColor(); + } + } + + var groupSize = ImGui.GetItemRectSize(); + var minBlockX = cursorStart.X + groupSize.X + style.ItemSpacing.X; + var availableAfterGroup = contentRightX - (cursorStart.X + groupSize.X); + var settingsButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog).X; + var pinIcon = _isWindowPinned ? FontAwesomeIcon.Lock : FontAwesomeIcon.Unlock; + var pinButtonWidth = _uiSharedService.GetIconButtonSize(pinIcon).X; + var blockWidth = rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth; + var desiredBlockX = availableAfterGroup > blockWidth + style.ItemSpacing.X + ? contentRightX - blockWidth + : minBlockX; + desiredBlockX = Math.Max(cursorStart.X, desiredBlockX); + var rulesPos = new Vector2(desiredBlockX, cursorStart.Y); + var settingsPos = new Vector2(desiredBlockX + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y); + var pinPos = new Vector2(settingsPos.X + settingsButtonWidth + style.ItemSpacing.X, cursorStart.Y); + + ImGui.SameLine(); + ImGui.SetCursorPos(rulesPos); + if (ImGui.Button("Rules", new Vector2(rulesButtonWidth, 0f))) + { + _showRulesOverlay = true; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Show chat rules"); + } + + ImGui.SameLine(); + ImGui.SetCursorPos(settingsPos); + if (_uiSharedService.IconButton(FontAwesomeIcon.Cog)) + { + ImGui.OpenPopup(SettingsPopupId); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Chat settings"); + } + + ImGui.SameLine(); + ImGui.SetCursorPos(pinPos); + using (ImRaii.PushId("window_pin_button")) + { + var restorePinColors = false; + if (_isWindowPinned) + { + var pinBase = UIColors.Get("LightlessPurpleDefault"); + var pinHover = UIColors.Get("LightlessPurple").WithAlpha(0.9f); + var pinActive = UIColors.Get("LightlessPurpleActive"); + ImGui.PushStyleColor(ImGuiCol.Button, pinBase); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, pinHover); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, pinActive); + restorePinColors = true; + } + + var pinClicked = _uiSharedService.IconButton(pinIcon); + + if (restorePinColors) + { + ImGui.PopStyleColor(3); + } + + if (pinClicked) + { + ToggleWindowPinned(); + } + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip(_isWindowPinned ? "Unpin window" : "Pin window"); + } + + DrawChatSettingsPopup(); + + ImGui.Separator(); + } + + private void ToggleWindowPinned() + { + _isWindowPinned = !_isWindowPinned; + _chatConfigService.Current.IsWindowPinned = _isWindowPinned; + _chatConfigService.Save(); + RefreshWindowFlags(); + } + + private void RefreshWindowFlags() + { + Flags = _unpinnedWindowFlags & ~ImGuiWindowFlags.NoCollapse; + if (_isWindowPinned) + { + Flags |= ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoResize; + } + } + + private void DrawChatSettingsPopup() + { + const ImGuiWindowFlags popupFlags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings; + if (!ImGui.BeginPopup(SettingsPopupId, popupFlags)) + return; + + ImGui.TextUnformatted("Chat Settings"); + ImGui.Separator(); + + var chatConfig = _chatConfigService.Current; + + var autoEnable = chatConfig.AutoEnableChatOnLogin; + if (ImGui.Checkbox("Auto-enable chat on login", ref autoEnable)) + { + chatConfig.AutoEnableChatOnLogin = autoEnable; + _chatConfigService.Save(); + if (autoEnable && !_zoneChatService.IsChatEnabled) + { + ToggleChatConnection(currentlyEnabled: false); + } + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Automatically connect to chat whenever Lightless loads."); + } + + var autoOpen = chatConfig.AutoOpenChatOnPluginLoad; + if (ImGui.Checkbox("Auto-open chat window on plugin load", ref autoOpen)) + { + chatConfig.AutoOpenChatOnPluginLoad = autoOpen; + _chatConfigService.Save(); + if (autoOpen) + { + IsOpen = true; + } + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Opens the chat window automatically whenever the plugin loads."); + } + + var showRules = chatConfig.ShowRulesOverlayOnOpen; + if (ImGui.Checkbox("Show rules overlay on open", ref showRules)) + { + chatConfig.ShowRulesOverlayOnOpen = showRules; + _chatConfigService.Save(); + _showRulesOverlay = showRules; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Toggles if the rules popup appears everytime the chat is opened for the first time."); + } + + var showTimestamps = chatConfig.ShowMessageTimestamps; + if (ImGui.Checkbox("Show message timestamps", ref showTimestamps)) + { + chatConfig.ShowMessageTimestamps = showTimestamps; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Toggles the timestamp prefix on messages."); + } + + var windowOpacity = Math.Clamp(chatConfig.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var opacityChanged = ImGui.SliderFloat("Window transparency", ref windowOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f"); + var resetOpacity = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetOpacity) + { + windowOpacity = DefaultWindowOpacity; + opacityChanged = true; + } + + if (opacityChanged) + { + chatConfig.ChatWindowOpacity = windowOpacity; + _chatConfigService.Save(); + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Adjust transparency of the chat window.\nRight-click to reset to default."); + } + + ImGui.EndPopup(); + } + + private void ToggleChatConnection(bool currentlyEnabled) + { + _ = Task.Run(async () => + { + try + { + await _zoneChatService.SetChatEnabledAsync(!currentlyEnabled).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to toggle chat connection"); + } + }); + } + + private void DrawChannelButtons(IReadOnlyList channels) + { + var style = ImGui.GetStyle(); + var baseFramePadding = style.FramePadding; + var available = ImGui.GetContentRegionAvail().X; + var buttonHeight = ImGui.GetFrameHeight(); + var arrowWidth = buttonHeight; + var hasChannels = channels.Count > 0; + var scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f; + if (hasChannels) + { + var minimumWidth = 120f * ImGuiHelpers.GlobalScale; + scrollWidth = Math.Max(scrollWidth, minimumWidth); + } + var scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f; + if (!hasChannels) + { + _pendingChannelScroll = null; + _channelScroll = 0f; + _channelScrollMax = 0f; + } + var prevScroll = hasChannels ? _channelScroll : 0f; + var prevMax = hasChannels ? _channelScrollMax : 0f; + float currentScroll = prevScroll; + float maxScroll = prevMax; + + ImGui.PushID("chat_channel_buttons"); + ImGui.BeginGroup(); + + using (ImRaii.Disabled(!hasChannels || prevScroll <= 0.5f)) + { + var arrowNormal = UIColors.Get("ButtonDefault"); + var arrowHovered = UIColors.Get("LightlessPurple").WithAlpha(0.85f); + var arrowActive = UIColors.Get("LightlessPurpleDefault").WithAlpha(0.75f); + ImGui.PushStyleColor(ImGuiCol.Button, arrowNormal); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, arrowHovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, arrowActive); + var clickedLeft = ImGui.ArrowButton("##chat_left", ImGuiDir.Left); + ImGui.PopStyleColor(3); + if (clickedLeft) + { + _pendingChannelScroll = Math.Max(0f, currentScroll - scrollStep); + } + } + + ImGui.SameLine(0f, style.ItemSpacing.X); + + var childHeight = buttonHeight + style.FramePadding.Y * 2f + style.ScrollbarSize; + var alignPushed = false; + if (hasChannels) + { + ImGui.PushStyleVar(ImGuiStyleVar.ButtonTextAlign, new Vector2(0f, 0.5f)); + alignPushed = true; + } + + const int MaxBadgeDisplay = 99; + + using (var child = ImRaii.Child("channel_scroll", new Vector2(scrollWidth, childHeight), false, ImGuiWindowFlags.HorizontalScrollbar)) + { + if (child) + { + var first = true; + foreach (var channel in channels) + { + if (!first) + ImGui.SameLine(); + + var isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); + var showBadge = !isSelected && channel.UnreadCount > 0; + var isZoneChannel = channel.Type == ChatChannelType.Zone; + var badgeText = string.Empty; + var badgePadding = Vector2.Zero; + var badgeTextSize = Vector2.Zero; + float badgeWidth = 0f; + float badgeHeight = 0f; + + var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); + var hovered = isSelected + ? UIColors.Get("LightlessPurple").WithAlpha(0.9f) + : UIColors.Get("ButtonDefault").WithAlpha(0.85f); + var active = isSelected + ? UIColors.Get("LightlessPurpleDefault").WithAlpha(0.8f) + : UIColors.Get("ButtonDefault").WithAlpha(0.75f); + + ImGui.PushStyleColor(ImGuiCol.Button, normal); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, hovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, active); + + if (showBadge) + { + var badgeSpacing = 4f * ImGuiHelpers.GlobalScale; + badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; + badgeText = channel.UnreadCount > MaxBadgeDisplay + ? $"{MaxBadgeDisplay}+" + : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); + badgeTextSize = ImGui.CalcTextSize(badgeText); + badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; + var customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y); + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding); + } + + var clicked = ImGui.Button($"{channel.DisplayName}##chat_channel_{channel.Key}"); + + if (showBadge) + { + ImGui.PopStyleVar(); + } + + ImGui.PopStyleColor(3); + + if (clicked && !isSelected) + { + _selectedChannelKey = channel.Key; + _zoneChatService.SetActiveChannel(channel.Key); + _scrollToBottom = true; + } + + var drawList = ImGui.GetWindowDrawList(); + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + + if (isZoneChannel) + { + var borderColor = UIColors.Get("LightlessOrange"); + var borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); + var borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale); + drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness); + } + + if (showBadge) + { + var buttonSizeY = itemMax.Y - itemMin.Y; + var badgeMin = new Vector2( + itemMin.X + baseFramePadding.X, + itemMin.Y + (buttonSizeY - badgeHeight) * 0.5f); + var badgeMax = badgeMin + new Vector2(badgeWidth, badgeHeight); + var badgeColor = UIColors.Get("DimRed"); + var badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor); + drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, badgeHeight * 0.5f); + var textPos = new Vector2( + badgeMin.X + (badgeWidth - badgeTextSize.X) * 0.5f, + badgeMin.Y + (badgeHeight - badgeTextSize.Y) * 0.5f); + drawList.AddText(textPos, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), badgeText); + } + + first = false; + } + + if (_pendingChannelScroll.HasValue) + { + ImGui.SetScrollX(_pendingChannelScroll.Value); + _pendingChannelScroll = null; + } + + currentScroll = ImGui.GetScrollX(); + maxScroll = ImGui.GetScrollMaxX(); + } + } + + if (alignPushed) + { + ImGui.PopStyleVar(); + } + + ImGui.SameLine(0f, style.ItemSpacing.X); + + using (ImRaii.Disabled(!hasChannels || prevScroll >= prevMax - 0.5f)) + { + var arrowNormal = UIColors.Get("ButtonDefault"); + var arrowHovered = UIColors.Get("LightlessPurple").WithAlpha(0.85f); + var arrowActive = UIColors.Get("LightlessPurpleDefault").WithAlpha(0.75f); + ImGui.PushStyleColor(ImGuiCol.Button, arrowNormal); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, arrowHovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, arrowActive); + var clickedRight = ImGui.ArrowButton("##chat_right", ImGuiDir.Right); + ImGui.PopStyleColor(3); + if (clickedRight) + { + _pendingChannelScroll = Math.Min(prevScroll + scrollStep, prevMax); + } + } + + ImGui.EndGroup(); + ImGui.PopID(); + + _channelScroll = currentScroll; + _channelScrollMax = maxScroll; + + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f); + } + + private readonly record struct ChatMessageContextAction(string Label, bool IsEnabled, Action Execute); +} diff --git a/LightlessSync/Utils/Crypto.cs b/LightlessSync/Utils/Crypto.cs index c31f82f..f4d2469 100644 --- a/LightlessSync/Utils/Crypto.cs +++ b/LightlessSync/Utils/Crypto.cs @@ -1,4 +1,7 @@ -using System.Security.Cryptography; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Security.Cryptography; using System.Text; namespace LightlessSync.Utils; @@ -9,8 +12,8 @@ public static class Crypto private const int _bufferSize = 65536; #pragma warning disable SYSLIB0021 // Type or member is obsolete - private static readonly Dictionary<(string, ushort), string> _hashListPlayersSHA256 = []; - private static readonly Dictionary _hashListSHA256 = new(StringComparer.Ordinal); + private static readonly ConcurrentDictionary<(string, ushort), string> _hashListPlayersSHA256 = new(); + private static readonly ConcurrentDictionary _hashListSHA256 = new(StringComparer.Ordinal); private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new(); public static string GetFileHash(this string filePath) @@ -42,25 +45,18 @@ public static class Crypto public static string GetHash256(this (string, ushort) playerToHash) { - if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash)) - return hash; - - return _hashListPlayersSHA256[playerToHash] = - BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(playerToHash.Item1 + playerToHash.Item2.ToString()))).Replace("-", "", StringComparison.Ordinal); + return _hashListPlayersSHA256.GetOrAdd(playerToHash, key => ComputeHashSHA256(key.Item1 + key.Item2.ToString())); } public static string GetHash256(this string stringToHash) { - return GetOrComputeHashSHA256(stringToHash); + return _hashListSHA256.GetOrAdd(stringToHash, ComputeHashSHA256); } - private static string GetOrComputeHashSHA256(string stringToCompute) + private static string ComputeHashSHA256(string stringToCompute) { - if (_hashListSHA256.TryGetValue(stringToCompute, out var hash)) - return hash; - - return _hashListSHA256[stringToCompute] = - BitConverter.ToString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal); + using var sha = SHA256.Create(); + return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal); } #pragma warning restore SYSLIB0021 // Type or member is obsolete } \ No newline at end of file diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 7507515..89ad891 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -4,9 +4,17 @@ using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.ImGuiSeStringRenderer; using Dalamud.Interface.Utility; +using Dalamud.Interface.Textures.TextureWraps; using Lumina.Text; +using Lumina.Text.Parse; +using Lumina.Text.ReadOnly; using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using System.Numerics; +using System.Reflection; +using System.Text; using System.Threading; using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString; using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder; @@ -19,6 +27,438 @@ public static class SeStringUtils private static int _seStringHitboxCounter; private static int _iconHitboxCounter; + public static bool TryRenderSeStringMarkupAtCursor(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var wrapWidth = ImGui.GetContentRegionAvail().X; + if (wrapWidth <= 0f || float.IsNaN(wrapWidth) || float.IsInfinity(wrapWidth)) + wrapWidth = float.MaxValue; + + var normalizedPayload = payload.ReplaceLineEndings("
"); + try + { + _ = ReadOnlySeString.FromMacroString(normalizedPayload, new MacroStringParseOptions + { + ExceptionMode = MacroStringParseExceptionMode.Throw + }); + } + catch (Exception) + { + return false; + } + + try + { + var drawParams = new SeStringDrawParams + { + WrapWidth = wrapWidth, + Font = ImGui.GetFont(), + Color = ImGui.GetColorU32(ImGuiCol.Text), + }; + + var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode()}"); + var drawResult = ImGuiHelpers.CompileSeStringWrapped(normalizedPayload, drawParams, renderId); + var height = drawResult.Size.Y; + if (height <= 0f) + height = ImGui.GetTextLineHeight(); + + ImGui.Dummy(new Vector2(0f, height)); + + if (drawResult.InteractedPayloadEnvelope.Length > 0 && + TryExtractLink(drawResult.InteractedPayloadEnvelope, drawResult.InteractedPayload, out var linkUrl, out var tooltipText)) + { + var hoverText = !string.IsNullOrEmpty(linkUrl) ? linkUrl : tooltipText; + + if (!string.IsNullOrEmpty(hoverText)) + { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(hoverText); + ImGui.EndTooltip(); + } + + if (!string.IsNullOrEmpty(linkUrl)) + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + + if (drawResult.Clicked && !string.IsNullOrEmpty(linkUrl)) + Dalamud.Utility.Util.OpenLink(linkUrl); + } + + return true; + } + catch (Exception ex) + { + ImGui.TextDisabled($"[SeString error] {ex.Message}"); + return false; + } + } + + public enum SeStringSegmentType + { + Text, + Icon + } + + public readonly record struct SeStringSegment( + SeStringSegmentType Type, + string? Text, + Vector4? Color, + uint IconId, + IDalamudTextureWrap? Texture, + Vector2 Size); + + public static bool TryResolveSegments( + string payload, + float scale, + Func iconResolver, + List resolvedSegments, + out Vector2 totalSize) + { + totalSize = Vector2.Zero; + if (string.IsNullOrWhiteSpace(payload)) + return false; + + var parsedSegments = new List(Math.Max(1, payload.Length / 4)); + if (!ParseSegments(payload, parsedSegments)) + return false; + + float width = 0f; + float height = 0f; + + foreach (var segment in parsedSegments) + { + switch (segment.Type) + { + case ParsedSegmentType.Text: + { + var text = segment.Text ?? string.Empty; + if (text.Length == 0) + break; + + var textSize = ImGui.CalcTextSize(text); + resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Text, text, segment.Color, 0, null, textSize)); + width += textSize.X; + height = MathF.Max(height, textSize.Y); + break; + } + case ParsedSegmentType.Icon: + { + var wrap = iconResolver(segment.IconId); + Vector2 iconSize; + string? fallback = null; + if (wrap != null) + { + iconSize = CalculateIconSize(wrap, scale); + } + else + { + fallback = $"[{segment.IconId}]"; + iconSize = ImGui.CalcTextSize(fallback); + } + + resolvedSegments.Add(new SeStringSegment(SeStringSegmentType.Icon, fallback, segment.Color, segment.IconId, wrap, iconSize)); + width += iconSize.X; + height = MathF.Max(height, iconSize.Y); + break; + } + } + } + + totalSize = new Vector2(width, height); + parsedSegments.Clear(); + return resolvedSegments.Count > 0; + } + + private enum ParsedSegmentType + { + Text, + Icon + } + + private readonly record struct ParsedSegment( + ParsedSegmentType Type, + string? Text, + uint IconId, + Vector4? Color); + + private static bool ParseSegments(string payload, List segments) + { + var builder = new StringBuilder(payload.Length); + Vector4? activeColor = null; + var index = 0; + while (index < payload.Length) + { + if (payload[index] == '<') + { + var end = payload.IndexOf('>', index); + if (end == -1) + break; + + var tagContent = payload.Substring(index + 1, end - index - 1); + if (TryHandleIconTag(tagContent, segments, builder, activeColor)) + { + index = end + 1; + continue; + } + + if (TryHandleColorTag(tagContent, segments, builder, ref activeColor)) + { + index = end + 1; + continue; + } + + builder.Append('<'); + builder.Append(tagContent); + builder.Append('>'); + index = end + 1; + } + else + { + builder.Append(payload[index]); + index++; + } + } + + if (index < payload.Length) + builder.Append(payload, index, payload.Length - index); + + FlushTextBuilder(builder, activeColor, segments); + return segments.Count > 0; + } + + private static bool TryHandleIconTag(string tagContent, List segments, StringBuilder textBuilder, Vector4? activeColor) + { + if (!tagContent.StartsWith("icon(", StringComparison.OrdinalIgnoreCase) || !tagContent.EndsWith(')')) + return false; + + var inner = tagContent.AsSpan(5, tagContent.Length - 6).Trim(); + if (!uint.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iconId)) + return false; + + FlushTextBuilder(textBuilder, activeColor, segments); + segments.Add(new ParsedSegment(ParsedSegmentType.Icon, null, iconId, null)); + return true; + } + + private static bool TryHandleColorTag(string tagContent, List segments, StringBuilder textBuilder, ref Vector4? activeColor) + { + if (tagContent.StartsWith("color", StringComparison.OrdinalIgnoreCase)) + { + var equalsIndex = tagContent.IndexOf('='); + if (equalsIndex == -1) + return false; + + var value = tagContent.Substring(equalsIndex + 1).Trim().Trim('"'); + if (!TryParseColor(value, out var color)) + return false; + + FlushTextBuilder(textBuilder, activeColor, segments); + activeColor = color; + return true; + } + + if (tagContent.Equals("/color", StringComparison.OrdinalIgnoreCase)) + { + FlushTextBuilder(textBuilder, activeColor, segments); + activeColor = null; + return true; + } + + return false; + } + + private static void FlushTextBuilder(StringBuilder builder, Vector4? color, List segments) + { + if (builder.Length == 0) + return; + + segments.Add(new ParsedSegment(ParsedSegmentType.Text, builder.ToString(), 0, color)); + builder.Clear(); + } + + private static bool TryExtractLink(ReadOnlySpan envelope, Payload? payload, out string url, out string? tooltipText) + { + url = string.Empty; + tooltipText = null; + + if (envelope.Length == 0 && payload is null) + return false; + + tooltipText = envelope.Length > 0 ? DalamudSeString.Parse(envelope.ToArray()).TextValue : null; + + if (payload is not null && TryReadUrlFromPayload(payload, out url)) + return true; + + if (!string.IsNullOrWhiteSpace(tooltipText)) + { + var candidate = FindFirstUrl(tooltipText); + if (!string.IsNullOrEmpty(candidate)) + { + url = candidate; + return true; + } + } + + url = string.Empty; + return false; + } + + private static bool TryReadUrlFromPayload(object payload, out string url) + { + url = string.Empty; + var type = payload.GetType(); + + static string? ReadStringProperty(Type type, object instance, string propertyName) + => type.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(instance) as string; + + string? candidate = ReadStringProperty(type, payload, "Uri") + ?? ReadStringProperty(type, payload, "Url") + ?? ReadStringProperty(type, payload, "Target") + ?? ReadStringProperty(type, payload, "Destination"); + + if (IsHttpUrl(candidate)) + { + url = candidate!; + return true; + } + + var dataProperty = type.GetProperty("Data", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (dataProperty?.GetValue(payload) is IEnumerable sequence) + { + foreach (var entry in sequence) + { + if (IsHttpUrl(entry)) + { + url = entry; + return true; + } + } + } + + var extraStringProp = type.GetProperty("ExtraString", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (IsHttpUrl(extraStringProp?.GetValue(payload) as string)) + { + url = (extraStringProp!.GetValue(payload) as string)!; + return true; + } + + var textProp = type.GetProperty("Text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (IsHttpUrl(textProp?.GetValue(payload) as string)) + { + url = (textProp!.GetValue(payload) as string)!; + return true; + } + + return false; + } + + private static string? FindFirstUrl(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + + var index = text.IndexOf("http", StringComparison.OrdinalIgnoreCase); + while (index >= 0) + { + var end = index; + while (end < text.Length && !char.IsWhiteSpace(text[end]) && !"\"')]>".Contains(text[end])) + end++; + + var candidate = text.Substring(index, end - index).TrimEnd('.', ',', ';'); + if (IsHttpUrl(candidate)) + return candidate; + + index = text.IndexOf("http", end, StringComparison.OrdinalIgnoreCase); + } + + return null; + } + + private static bool IsHttpUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + return Uri.TryCreate(value, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + } + + public static string StripMarkup(string value) + { + if (string.IsNullOrEmpty(value)) + return string.Empty; + + var builder = new StringBuilder(value.Length); + int depth = 0; + foreach (var c in value) + { + if (c == '<') + { + depth++; + continue; + } + + if (c == '>' && depth > 0) + { + depth--; + continue; + } + + if (depth == 0) + builder.Append(c); + } + + return builder.ToString().Trim(); + } + + private static bool TryParseColor(string value, out Vector4 color) + { + color = default; + if (string.IsNullOrEmpty(value) || value[0] != '#') + return false; + + var hex = value.AsSpan(1); + if (!uint.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + return false; + + byte a, r, g, b; + if (hex.Length == 6) + { + a = 0xFF; + r = (byte)(parsed >> 16); + g = (byte)(parsed >> 8); + b = (byte)parsed; + } + else if (hex.Length == 8) + { + a = (byte)(parsed >> 24); + r = (byte)(parsed >> 16); + g = (byte)(parsed >> 8); + b = (byte)parsed; + } + else + { + return false; + } + + const float inv = 1.0f / 255f; + color = new Vector4(r * inv, g * inv, b * inv, a * inv); + return true; + } + + private static Vector2 CalculateIconSize(IDalamudTextureWrap wrap, float scale) + { + const float IconHeightScale = 1.25f; + + var textHeight = ImGui.GetTextLineHeight(); + var baselineHeight = MathF.Max(1f, textHeight - 2f * scale); + var targetHeight = MathF.Max(baselineHeight, baselineHeight * IconHeightScale); + var aspect = wrap.Width > 0 ? wrap.Width / (float)wrap.Height : 1f; + return new Vector2(targetHeight * aspect, targetHeight); + } + public static DalamudSeString BuildFormattedPlayerName(string text, Vector4? textColor, Vector4? glowColor) { var b = new DalamudSeStringBuilder(); diff --git a/LightlessSync/Utils/VariousExtensions.cs b/LightlessSync/Utils/VariousExtensions.cs index 9215893..e0fd466 100644 --- a/LightlessSync/Utils/VariousExtensions.cs +++ b/LightlessSync/Utils/VariousExtensions.cs @@ -1,9 +1,10 @@ using Dalamud.Game.ClientState.Objects.Types; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; -using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; using System.Text.Json; namespace LightlessSync.Utils; @@ -56,9 +57,20 @@ public static class VariousExtensions } public static Dictionary> CheckUpdatedData(this CharacterData newData, Guid applicationBase, - CharacterData? oldData, ILogger logger, PairHandler cachedPlayer, bool forceApplyCustomization, bool forceApplyMods) + 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()) { @@ -91,7 +103,9 @@ public static class VariousExtensions { if (hasNewAndOldFileReplacements) { - bool listsAreEqual = oldData.FileReplacements[objectKind].SequenceEqual(newData.FileReplacements[objectKind], PlayerData.Data.FileReplacementDataComparer.Instance); + var oldList = oldData.FileReplacements[objectKind]; + var newList = newData.FileReplacements[objectKind]; + var listsAreEqual = FileReplacementsEquivalent(oldList, newList); if (!listsAreEqual || forceApplyMods) { logger.LogDebug("[BASE-{appBase}] Updating {object}/{kind} (FileReplacements not equal) => {change}", applicationBase, cachedPlayer, objectKind, PlayerChanges.ModFiles); @@ -114,9 +128,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") && !g.EndsWith("tex") && !g.EndsWith("mtrl"))) + var existingTransients = existingFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) .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") && !g.EndsWith("tex") && !g.EndsWith("mtrl"))) + var newTransients = newFileReplacements.Where(g => g.GamePaths.Any(g => !g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("tex", StringComparison.OrdinalIgnoreCase) && !g.EndsWith("mtrl", StringComparison.OrdinalIgnoreCase))) .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, diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index b8f81f2..d7fff31 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -7,6 +7,7 @@ using LightlessSync.FileCache; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; using System; @@ -28,16 +29,23 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly FileTransferOrchestrator _orchestrator; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly LightlessConfigService _configService; + private readonly TextureDownscaleService _textureDownscaleService; + private readonly TextureMetadataHelper _textureMetadataHelper; private readonly ConcurrentDictionary _activeDownloadStreams; private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; private bool _lastConfigDirectDownloadsState; - public FileDownloadManager(ILogger logger, LightlessMediator mediator, + public FileDownloadManager( + ILogger logger, + LightlessMediator mediator, FileTransferOrchestrator orchestrator, - FileCacheManager fileCacheManager, FileCompactor fileCompactor, - PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService) : base(logger, mediator) + FileCacheManager fileCacheManager, + FileCompactor fileCompactor, + PairProcessingLimiter pairProcessingLimiter, + LightlessConfigService configService, + TextureDownscaleService textureDownscaleService, TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) { _downloadStatus = new Dictionary(StringComparer.Ordinal); _orchestrator = orchestrator; @@ -45,6 +53,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _fileCompactor = fileCompactor; _pairProcessingLimiter = pairProcessingLimiter; _configService = configService; + _textureDownscaleService = textureDownscaleService; + _textureMetadataHelper = textureMetadataHelper; _activeDownloadStreams = new(); _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; @@ -63,6 +73,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public List CurrentDownloads { get; private set; } = []; public List ForbiddenTransfers => _orchestrator.ForbiddenTransfers; + public Guid? CurrentOwnerToken { get; private set; } public bool IsDownloading => CurrentDownloads.Any(); @@ -83,14 +94,15 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { CurrentDownloads.Clear(); _downloadStatus.Clear(); + CurrentOwnerToken = null; } - public async Task DownloadFiles(GameObjectHandler gameObject, List fileReplacementDto, CancellationToken ct) + public async Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, CancellationToken ct, bool skipDownscale = false) { Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles))); try { - await DownloadFilesInternal(gameObject, fileReplacementDto, ct).ConfigureAwait(false); + await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale).ConfigureAwait(false); } catch { @@ -98,7 +110,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } finally { - Mediator.Publish(new DownloadFinishedMessage(gameObject)); + if (gameObject is not null) + { + Mediator.Publish(new DownloadFinishedMessage(gameObject)); + } Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles))); } } @@ -272,7 +287,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase int bytesRead; try { - var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).AsTask(); + using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); + var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), readCancellation.Token).AsTask(); while (!readTask.IsCompleted) { var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false); @@ -286,6 +302,20 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var snapshot = _pairProcessingLimiter.GetSnapshot(); if (snapshot.Waiting > 0) { + readCancellation.Cancel(); + try + { + await readTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected when cancelling the read due to timeout + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Error finishing read task after stall detection for {requestUrl}", requestUrl); + } + throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})"); } @@ -352,7 +382,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List fileReplacement, string downloadLabel) + private async Task DecompressBlockFileAsync(string downloadStatusKey, string blockFilePath, List fileReplacement, string downloadLabel, bool skipDownscale) { if (_downloadStatus.TryGetValue(downloadStatusKey, out var status)) { @@ -385,7 +415,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var decompressedFile = LZ4Wrapper.Unwrap(compressedFileContent); await _fileCompactor.WriteAllBytesAsync(filePath, decompressedFile, CancellationToken.None).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath); + var gamePath = fileReplacement.FirstOrDefault(f => string.Equals(f.Hash, fileHash, StringComparison.OrdinalIgnoreCase))?.GamePaths.FirstOrDefault() ?? string.Empty; + PersistFileToStorage(fileHash, filePath, gamePath, skipDownscale); } catch (EndOfStreamException) { @@ -413,7 +444,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } private async Task PerformDirectDownloadFallbackAsync(DownloadFileTransfer directDownload, List fileReplacement, - IProgress progress, CancellationToken token, bool slotAlreadyAcquired) + IProgress progress, CancellationToken token, bool skipDownscale, bool slotAlreadyAcquired) { if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) { @@ -455,7 +486,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); } - await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}").ConfigureAwait(false); + await DecompressBlockFileAsync(downloadKey, blockFile, fileReplacement, $"fallback-{directDownload.Hash}", skipDownscale).ConfigureAwait(false); } finally { @@ -478,8 +509,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } - public async Task> InitiateDownloadList(GameObjectHandler gameObjectHandler, List fileReplacement, CancellationToken ct) + public async Task> InitiateDownloadList(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, Guid? ownerToken = null) { + CurrentOwnerToken = ownerToken; var objectName = gameObjectHandler?.Name ?? "Unknown"; Logger.LogDebug("Download start: {id}", objectName); @@ -520,7 +552,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return CurrentDownloads; } - private async Task DownloadFilesInternal(GameObjectHandler gameObjectHandler, List fileReplacement, CancellationToken ct) + private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale) { var objectName = gameObjectHandler?.Name ?? "Unknown"; @@ -583,7 +615,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogWarning("Downloading {direct} files directly, and {batchtotal} in {batches} batches.", directDownloads.Count, batchDownloads.Count, downloadBatches.Length); } - Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + if (gameObjectHandler is not null) + { + Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + } Task batchDownloadsTask = downloadBatches.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(downloadBatches, new ParallelOptions() { @@ -651,7 +686,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return; } - await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name).ConfigureAwait(false); + await DecompressBlockFileAsync(fileGroup.Key, blockFile, fileReplacement, fi.Name, skipDownscale).ConfigureAwait(false); } finally { @@ -690,14 +725,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!ShouldUseDirectDownloads()) { - await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAlreadyAcquired: false).ConfigureAwait(false); + await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, skipDownscale, slotAlreadyAcquired: false).ConfigureAwait(false); return; } var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); var slotAcquired = false; - try { downloadTracker.DownloadStatus = DownloadStatus.WaitingForSlot; @@ -727,7 +761,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename).ConfigureAwait(false); var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, CancellationToken.None).ConfigureAwait(false); - PersistFileToStorage(directDownload.Hash, finalFilename); + PersistFileToStorage(directDownload.Hash, finalFilename, replacement.GamePaths[0], skipDownscale); downloadTracker.TransferredFiles = 1; Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); @@ -739,8 +773,15 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } catch (OperationCanceledException ex) { - Logger.LogDebug("{hash}: Detected cancellation of direct download, discarding file.", directDownload.Hash); - Logger.LogError(ex, "{hash}: Error during direct download.", directDownload.Hash); + if (token.IsCancellationRequested) + { + Logger.LogDebug("{hash}: Direct download cancelled by caller, discarding file.", directDownload.Hash); + } + else + { + Logger.LogWarning(ex, "{hash}: Direct download cancelled unexpectedly.", directDownload.Hash); + } + ClearDownload(); return; } @@ -762,7 +803,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { downloadTracker.DownloadStatus = DownloadStatus.WaitingForQueue; - await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, slotAcquired).ConfigureAwait(false); + await PerformDirectDownloadFallbackAsync(directDownload, fileReplacement, progress, token, skipDownscale, slotAcquired).ConfigureAwait(false); if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) { @@ -815,7 +856,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; } - private void PersistFileToStorage(string fileHash, string filePath) + private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale) { var fi = new FileInfo(filePath); Func RandomDayInThePast() @@ -832,6 +873,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { var entry = _fileDbManager.CreateCacheEntry(filePath); + var mapKind = _textureMetadataHelper.DetermineMapKind(gamePath, filePath); + if (!skipDownscale) + { + _textureDownscaleService.ScheduleDownscale(fileHash, filePath, mapKind); + } if (entry != null && !string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) { Logger.LogError("Hash mismatch after extracting, got {hash}, expected {expectedHash}, deleting file", entry.Hash, fileHash); diff --git a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs index de84a81..d6937c4 100644 --- a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs +++ b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs @@ -4,8 +4,10 @@ using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Net.Sockets; using System.Reflection; namespace LightlessSync.WebAPI.Files; @@ -84,27 +86,46 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true) { - using var requestMessage = new HttpRequestMessage(method, uri); - return await SendRequestInternalAsync(requestMessage, ct, httpCompletionOption, withToken).ConfigureAwait(false); + return await SendRequestInternalAsync(() => new HttpRequestMessage(method, uri), + ct, httpCompletionOption, withToken, allowRetry: true).ConfigureAwait(false); } public async Task SendRequestAsync(HttpMethod method, Uri uri, T content, CancellationToken ct, bool withToken = true) where T : class { - using var requestMessage = new HttpRequestMessage(method, uri); - if (content is not ByteArrayContent) - requestMessage.Content = JsonContent.Create(content); - else - requestMessage.Content = content as ByteArrayContent; - return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false); + return await SendRequestInternalAsync(() => + { + var requestMessage = new HttpRequestMessage(method, uri); + if (content is not ByteArrayContent byteArrayContent) + { + requestMessage.Content = JsonContent.Create(content); + } + else + { + var clonedContent = new ByteArrayContent(byteArrayContent.ReadAsByteArrayAsync().GetAwaiter().GetResult()); + foreach (var header in byteArrayContent.Headers) + { + clonedContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + requestMessage.Content = clonedContent; + } + + return requestMessage; + }, ct, HttpCompletionOption.ResponseContentRead, withToken, + allowRetry: content is not HttpContent || content is ByteArrayContent).ConfigureAwait(false); } public async Task SendRequestStreamAsync(HttpMethod method, Uri uri, ProgressableStreamContent content, CancellationToken ct, bool withToken = true) { - using var requestMessage = new HttpRequestMessage(method, uri); - requestMessage.Content = content; - return await SendRequestInternalAsync(requestMessage, ct, withToken: withToken).ConfigureAwait(false); + return await SendRequestInternalAsync(() => + { + var requestMessage = new HttpRequestMessage(method, uri) + { + Content = content + }; + return requestMessage; + }, ct, HttpCompletionOption.ResponseContentRead, withToken, allowRetry: false).ConfigureAwait(false); } public async Task WaitForDownloadSlotAsync(CancellationToken token) @@ -146,39 +167,78 @@ public class FileTransferOrchestrator : DisposableMediatorSubscriberBase return Math.Clamp(dividedLimit, 1, long.MaxValue); } - private async Task SendRequestInternalAsync(HttpRequestMessage requestMessage, - CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, bool withToken = true) + private async Task SendRequestInternalAsync(Func requestFactory, + CancellationToken? ct = null, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead, + bool withToken = true, bool allowRetry = true) { - if (withToken) - { - var token = await _tokenProvider.GetToken().ConfigureAwait(false); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - } + const int maxAttempts = 2; + var attempt = 0; - if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent) + while (true) { - var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false); - Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content); - } - else - { - Logger.LogDebug("Sending {method} to {uri}", requestMessage.Method, requestMessage.RequestUri); - } + attempt++; + using var requestMessage = requestFactory(); - try - { - if (ct != null) - return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false); - return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - throw; - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Error during SendRequestInternal for {uri}", requestMessage.RequestUri); - throw; + if (withToken) + { + var token = await _tokenProvider.GetToken().ConfigureAwait(false); + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + if (requestMessage.Content != null && requestMessage.Content is not StreamContent && requestMessage.Content is not ByteArrayContent) + { + var content = await ((JsonContent)requestMessage.Content).ReadAsStringAsync().ConfigureAwait(false); + Logger.LogDebug("Sending {method} to {uri} (Content: {content})", requestMessage.Method, requestMessage.RequestUri, content); + } + else + { + Logger.LogDebug("Sending {method} to {uri}", requestMessage.Method, requestMessage.RequestUri); + } + + try + { + if (ct != null) + return await _httpClient.SendAsync(requestMessage, httpCompletionOption, ct.Value).ConfigureAwait(false); + return await _httpClient.SendAsync(requestMessage, httpCompletionOption).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) when (allowRetry && attempt < maxAttempts && IsTransientNetworkException(ex)) + { + Logger.LogWarning(ex, "Transient error during SendRequestInternal for {uri}, retrying attempt {attempt}/{maxAttempts}", + requestMessage.RequestUri, attempt, maxAttempts); + if (ct.HasValue) + { + await Task.Delay(TimeSpan.FromMilliseconds(200), ct.Value).ConfigureAwait(false); + } + else + { + await Task.Delay(TimeSpan.FromMilliseconds(200)).ConfigureAwait(false); + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Error during SendRequestInternal for {uri}", requestMessage.RequestUri); + throw; + } } } + + private static bool IsTransientNetworkException(Exception ex) + { + var current = ex; + while (current != null) + { + if (current is SocketException socketEx) + { + return socketEx.SocketErrorCode is SocketError.ConnectionReset or SocketError.ConnectionAborted or SocketError.TimedOut; + } + + current = current.InnerException; + } + + return false; + } } \ No newline at end of file diff --git a/LightlessSync/WebAPI/Files/FileUploadManager.cs b/LightlessSync/WebAPI/Files/FileUploadManager.cs index 09be269..4fb89b7 100644 --- a/LightlessSync/WebAPI/Files/FileUploadManager.cs +++ b/LightlessSync/WebAPI/Files/FileUploadManager.cs @@ -44,6 +44,7 @@ public sealed class FileUploadManager : DisposableMediatorSubscriberBase } public List CurrentUploads { get; } = []; + public bool IsReady => _orchestrator.IsInitialized; public bool IsUploading { get diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index a4c78f8..0a39219 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -1,5 +1,6 @@ -using LightlessSync.API.Data; +using LightlessSync.API.Data; using LightlessSync.API.Dto; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using Microsoft.AspNetCore.SignalR.Client; @@ -41,6 +42,30 @@ public partial class ApiController await _lightlessHub!.SendAsync(nameof(TryPairWithContentId), otherCid).ConfigureAwait(false); } + public async Task UpdateChatPresence(ChatPresenceUpdateDto presence) + { + if (!IsConnected || _lightlessHub is null) return; + await _lightlessHub.InvokeAsync(nameof(UpdateChatPresence), presence).ConfigureAwait(false); + } + + public async Task SendChatMessage(ChatSendRequestDto request) + { + if (!IsConnected || _lightlessHub is null) return; + await _lightlessHub.InvokeAsync(nameof(SendChatMessage), request).ConfigureAwait(false); + } + + public async Task ReportChatMessage(ChatReportSubmitDto request) + { + if (!IsConnected || _lightlessHub is null) return; + await _lightlessHub.InvokeAsync(nameof(ReportChatMessage), request).ConfigureAwait(false); + } + + public async Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) + { + if (!IsConnected || _lightlessHub is null) return null; + return await _lightlessHub.InvokeAsync(nameof(ResolveChatParticipant), request).ConfigureAwait(false); + } + public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null) { CheckConnection(); @@ -88,6 +113,12 @@ public partial class ApiController return await _lightlessHub!.InvokeAsync(nameof(UserGetProfile), dto).ConfigureAwait(false); } + public async Task UserGetLightfinderProfile(string hashedCid) + { + if (!IsConnected) return null; + return await _lightlessHub!.InvokeAsync(nameof(UserGetLightfinderProfile), hashedCid).ConfigureAwait(false); + } + public async Task UserPushData(UserCharaDataMessageDto dto) { try diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs index 8323fc3..490800f 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Callbacks.cs @@ -1,7 +1,9 @@ +using System; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto; using LightlessSync.API.Dto.CharaData; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration.Models; @@ -24,21 +26,21 @@ public partial class ApiController public Task Client_GroupChangePermissions(GroupPermissionDto groupPermission) { Logger.LogTrace("Client_GroupChangePermissions: {perm}", groupPermission); - ExecuteSafely(() => _pairManager.SetGroupPermissions(groupPermission)); + ExecuteSafely(() => _pairCoordinator.HandleGroupChangePermissions(groupPermission)); return Task.CompletedTask; } public Task Client_GroupChangeUserPairPermissions(GroupPairUserPermissionDto dto) { Logger.LogDebug("Client_GroupChangeUserPairPermissions: {dto}", dto); - ExecuteSafely(() => _pairManager.UpdateGroupPairPermissions(dto)); + ExecuteSafely(() => _pairCoordinator.HandleGroupPairPermissions(dto)); return Task.CompletedTask; } public Task Client_GroupDelete(GroupDto groupDto) { Logger.LogTrace("Client_GroupDelete: {dto}", groupDto); - ExecuteSafely(() => _pairManager.RemoveGroup(groupDto.Group)); + ExecuteSafely(() => _pairCoordinator.HandleGroupRemoved(groupDto)); return Task.CompletedTask; } @@ -47,8 +49,8 @@ public partial class ApiController Logger.LogTrace("Client_GroupPairChangeUserInfo: {dto}", userInfo); ExecuteSafely(() => { - if (string.Equals(userInfo.UID, UID, StringComparison.Ordinal)) _pairManager.SetGroupStatusInfo(userInfo); - else _pairManager.SetGroupPairStatusInfo(userInfo); + var isSelf = string.Equals(userInfo.UID, UID, StringComparison.Ordinal); + _pairCoordinator.HandleGroupPairStatus(userInfo, isSelf); }); return Task.CompletedTask; } @@ -56,28 +58,28 @@ public partial class ApiController public Task Client_GroupPairJoined(GroupPairFullInfoDto groupPairInfoDto) { Logger.LogTrace("Client_GroupPairJoined: {dto}", groupPairInfoDto); - ExecuteSafely(() => _pairManager.AddGroupPair(groupPairInfoDto)); + ExecuteSafely(() => _pairCoordinator.HandleGroupPairJoined(groupPairInfoDto)); return Task.CompletedTask; } public Task Client_GroupPairLeft(GroupPairDto groupPairDto) { Logger.LogTrace("Client_GroupPairLeft: {dto}", groupPairDto); - ExecuteSafely(() => _pairManager.RemoveGroupPair(groupPairDto)); + ExecuteSafely(() => _pairCoordinator.HandleGroupPairLeft(groupPairDto)); return Task.CompletedTask; } public Task Client_GroupSendFullInfo(GroupFullInfoDto groupInfo) { Logger.LogTrace("Client_GroupSendFullInfo: {dto}", groupInfo); - ExecuteSafely(() => _pairManager.AddGroup(groupInfo)); + ExecuteSafely(() => _pairCoordinator.HandleGroupFullInfo(groupInfo)); return Task.CompletedTask; } public Task Client_GroupSendInfo(GroupInfoDto groupInfo) { Logger.LogTrace("Client_GroupSendInfo: {dto}", groupInfo); - ExecuteSafely(() => _pairManager.SetGroupInfo(groupInfo)); + ExecuteSafely(() => _pairCoordinator.HandleGroupInfoUpdate(groupInfo)); return Task.CompletedTask; } @@ -129,52 +131,62 @@ public partial class ApiController return Task.CompletedTask; } + public Task Client_ChatReceive(ChatMessageDto message) + { + Logger.LogTrace("Client_ChatReceive: {@channel}", message.Channel); + ExecuteSafely(() => ChatMessageReceived?.Invoke(message)); + return Task.CompletedTask; + } + public Task Client_UpdateUserIndividualPairStatusDto(UserIndividualPairStatusDto dto) { Logger.LogDebug("Client_UpdateUserIndividualPairStatusDto: {dto}", dto); - ExecuteSafely(() => _pairManager.UpdateIndividualPairStatus(dto)); + ExecuteSafely(() => _pairCoordinator.HandleUserStatus(dto)); return Task.CompletedTask; } public Task Client_UserAddClientPair(UserPairDto dto) { Logger.LogDebug("Client_UserAddClientPair: {dto}", dto); - ExecuteSafely(() => _pairManager.AddUserPair(dto, addToLastAddedUser: true)); + ExecuteSafely(() => _pairCoordinator.HandleUserAddPair(dto, addToLastAddedUser: true)); return Task.CompletedTask; } public Task Client_UserReceiveCharacterData(OnlineUserCharaDataDto dataDto) { Logger.LogTrace("Client_UserReceiveCharacterData: {user}", dataDto.User); - ExecuteSafely(() => _pairManager.ReceiveCharaData(dataDto)); + ExecuteSafely(() => _pairCoordinator.HandleCharacterData(dataDto)); return Task.CompletedTask; } public Task Client_UserReceiveUploadStatus(UserDto dto) { Logger.LogTrace("Client_UserReceiveUploadStatus: {dto}", dto); - ExecuteSafely(() => _pairManager.ReceiveUploadStatus(dto)); + ExecuteSafely(() => + { + _pairCoordinator.HandleUploadStatus(dto); + }); return Task.CompletedTask; } public Task Client_UserRemoveClientPair(UserDto dto) { Logger.LogDebug("Client_UserRemoveClientPair: {dto}", dto); - ExecuteSafely(() => _pairManager.RemoveUserPair(dto)); + ExecuteSafely(() => _pairCoordinator.HandleUserRemovePair(dto)); return Task.CompletedTask; } public Task Client_UserSendOffline(UserDto dto) { Logger.LogDebug("Client_UserSendOffline: {dto}", dto); - ExecuteSafely(() => _pairManager.MarkPairOffline(dto.User)); + ExecuteSafely(() => _pairCoordinator.HandleUserOffline(dto.User)); return Task.CompletedTask; } public Task Client_UserSendOnline(OnlineUserIdentDto dto) { Logger.LogDebug("Client_UserSendOnline: {dto}", dto); - ExecuteSafely(() => _pairManager.MarkPairOnline(dto)); + ExecuteSafely(() => _pairCoordinator.HandleUserOnline(dto, sendNotification: true)); return Task.CompletedTask; } @@ -188,7 +200,7 @@ public partial class ApiController public Task Client_UserUpdateOtherPairPermissions(UserPermissionsDto dto) { Logger.LogDebug("Client_UserUpdateOtherPairPermissions: {dto}", dto); - ExecuteSafely(() => _pairManager.UpdatePairPermissions(dto)); + ExecuteSafely(() => _pairCoordinator.HandleUserPermissions(dto)); return Task.CompletedTask; } @@ -209,7 +221,7 @@ public partial class ApiController public Task Client_UserUpdateSelfPairPermissions(UserPermissionsDto dto) { Logger.LogDebug("Client_UserUpdateSelfPairPermissions: {dto}", dto); - ExecuteSafely(() => _pairManager.UpdateSelfPairPermissions(dto)); + ExecuteSafely(() => _pairCoordinator.HandleSelfPermissions(dto)); return Task.CompletedTask; } diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index d2fddc5..c184fdb 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -1,9 +1,14 @@ +using System; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; using Dalamud.Utility; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.User; using LightlessSync.API.SignalR; using LightlessSync.LightlessConfiguration; @@ -16,7 +21,6 @@ using LightlessSync.WebAPI.SignalR; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; -using System.Reflection; namespace LightlessSync.WebAPI; @@ -28,7 +32,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private readonly DalamudUtilService _dalamudUtil; private readonly HubFactory _hubFactory; - private readonly PairManager _pairManager; + private readonly PairCoordinator _pairCoordinator; private readonly PairRequestService _pairRequestService; private readonly ServerConfigurationManager _serverManager; private readonly TokenProvider _tokenProvider; @@ -42,14 +46,17 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private HubConnection? _lightlessHub; private ServerState _serverState; private CensusUpdateMessage? _lastCensus; + private IReadOnlyList _zoneChatChannels = Array.Empty(); + private IReadOnlyList _groupChatChannels = Array.Empty(); + private event Action? ChatMessageReceived; public ApiController(ILogger logger, HubFactory hubFactory, DalamudUtilService dalamudUtil, - PairManager pairManager, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator, + PairCoordinator pairCoordinator, PairRequestService pairRequestService, ServerConfigurationManager serverManager, LightlessMediator mediator, TokenProvider tokenProvider, LightlessConfigService lightlessConfigService, NotificationService lightlessNotificationService) : base(logger, mediator) { _hubFactory = hubFactory; _dalamudUtil = dalamudUtil; - _pairManager = pairManager; + _pairCoordinator = pairCoordinator; _pairRequestService = pairRequestService; _serverManager = serverManager; _tokenProvider = tokenProvider; @@ -61,7 +68,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Mediator.Subscribe(this, (msg) => LightlessHubOnClosed(msg.Exception)); Mediator.Subscribe(this, (msg) => _ = LightlessHubOnReconnectedAsync()); Mediator.Subscribe(this, (msg) => LightlessHubOnReconnecting(msg.Exception)); - Mediator.Subscribe(this, (msg) => _ = CyclePauseAsync(msg.UserData)); + Mediator.Subscribe(this, (msg) => _ = CyclePauseAsync(msg.Pair)); Mediator.Subscribe(this, (msg) => _lastCensus = msg); Mediator.Subscribe(this, (msg) => _ = PauseAsync(msg.UserData)); @@ -106,15 +113,65 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL public SystemInfoDto SystemInfoDto { get; private set; } = new(); + public IReadOnlyList ZoneChatChannels => _zoneChatChannels; + public IReadOnlyList GroupChatChannels => _groupChatChannels; public string UID => _connectionDto?.User.UID ?? string.Empty; public event Action? OnConnected; public async Task CheckClientHealth() { - return await _lightlessHub!.InvokeAsync(nameof(CheckClientHealth)).ConfigureAwait(false); + var hub = _lightlessHub; + if (hub is null || !IsConnected) + { + return false; + } + + try + { + return await hub.InvokeAsync(nameof(CheckClientHealth)).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Client health check failed."); + return false; + } } + public async Task RefreshChatChannelsAsync() + { + if (_lightlessHub is null || !IsConnected) + return; + + await Task.WhenAll(GetZoneChatChannelsAsync(), GetGroupChatChannelsAsync()).ConfigureAwait(false); + } + + public async Task> GetZoneChatChannelsAsync() + { + if (_lightlessHub is null || !IsConnected) + return _zoneChatChannels; + + var channels = await _lightlessHub.InvokeAsync>("GetZoneChatChannels").ConfigureAwait(false); + _zoneChatChannels = channels; + return channels; + } + + public async Task> GetGroupChatChannelsAsync() + { + if (_lightlessHub is null || !IsConnected) + return _groupChatChannels; + + var channels = await _lightlessHub.InvokeAsync>("GetGroupChatChannels").ConfigureAwait(false); + _groupChatChannels = channels; + return channels; + } + + Task> ILightlessHub.GetZoneChatChannels() + => _lightlessHub!.InvokeAsync>("GetZoneChatChannels"); + + Task> ILightlessHub.GetGroupChatChannels() + => _lightlessHub!.InvokeAsync>("GetGroupChatChannels"); + public async Task CreateConnectionsAsync() { if (!_serverManager.ShownCensusPopup) @@ -337,35 +394,86 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private bool _naggedAboutLod = false; - public Task CyclePauseAsync(UserData userData) + public Task CyclePauseAsync(Pair pair) { - CancellationTokenSource cts = new(); - cts.CancelAfter(TimeSpan.FromSeconds(5)); + ArgumentNullException.ThrowIfNull(pair); + return CyclePauseAsync(pair.UniqueIdent); + } + + public Task CyclePauseAsync(PairUniqueIdentifier ident) + { + var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); _ = Task.Run(async () => { - var pair = _pairManager.GetOnlineUserPairs().Single(p => p.UserPair != null && p.UserData == userData); - var perm = pair.UserPair!.OwnPermissions; - perm.SetPaused(paused: true); - await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false); - // wait until it's changed - while (pair.UserPair!.OwnPermissions != perm) + var token = timeoutCts.Token; + try { - await Task.Delay(250, cts.Token).ConfigureAwait(false); - Logger.LogTrace("Waiting for permissions change for {data}", userData); + if (!_pairCoordinator.Ledger.TryGetEntry(ident, out var entry) || entry is null) + { + Logger.LogWarning("CyclePauseAsync: pair {uid} not found in ledger", ident.UserId); + return; + } + + var originalPermissions = entry.SelfPermissions; + var targetPermissions = originalPermissions; + targetPermissions.SetPaused(!originalPermissions.IsPaused()); + + await UserSetPairPermissions(new UserPermissionsDto(entry.User, targetPermissions)).ConfigureAwait(false); + + var applied = false; + while (!token.IsCancellationRequested) + { + if (_pairCoordinator.Ledger.TryGetEntry(ident, out var updated) && updated is not null) + { + if (updated.SelfPermissions == targetPermissions) + { + applied = true; + entry = updated; + break; + } + } + + await Task.Delay(250, token).ConfigureAwait(false); + Logger.LogTrace("Waiting for permissions change for {uid}", ident.UserId); + } + + if (!applied) + { + 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()); } - perm.SetPaused(paused: false); - await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false); - }, cts.Token).ContinueWith((t) => cts.Dispose()); + catch (OperationCanceledException) + { + Logger.LogDebug("CyclePauseAsync cancelled for {uid}", ident.UserId); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "CyclePauseAsync failed for {uid}", ident.UserId); + } + finally + { + timeoutCts.Dispose(); + } + }, CancellationToken.None); return Task.CompletedTask; } public async Task PauseAsync(UserData userData) { - var pair = _pairManager.GetOnlineUserPairs().Single(p => p.UserPair != null && p.UserData == userData); - var perm = pair.UserPair!.OwnPermissions; - perm.SetPaused(paused: true); - await UserSetPairPermissions(new UserPermissionsDto(userData, perm)).ConfigureAwait(false); + 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); + return; + } + + var permissions = entry.SelfPermissions; + permissions.SetPaused(paused: true); + await UserSetPairPermissions(new UserPermissionsDto(userData, permissions)).ConfigureAwait(false); } public Task GetConnectionDto() => GetConnectionDtoAsync(true); @@ -388,8 +496,13 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL private async Task ClientHealthCheckAsync(CancellationToken ct) { - while (!ct.IsCancellationRequested && _lightlessHub != null) + while (!ct.IsCancellationRequested) { + if (_lightlessHub is null) + { + break; + } + await Task.Delay(TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); Logger.LogDebug("Checking Client Health State"); @@ -455,6 +568,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGroupSendInfo((dto) => _ = Client_GroupSendInfo(dto)); OnGroupUpdateProfile((dto) => _ = Client_GroupSendProfile(dto)); OnGroupChangeUserPairPermissions((dto) => _ = Client_GroupChangeUserPairPermissions(dto)); + _lightlessHub.On(nameof(Client_ChatReceive), (Func)Client_ChatReceive); OnGposeLobbyJoin((dto) => _ = Client_GposeLobbyJoin(dto)); OnGposeLobbyLeave((dto) => _ = Client_GposeLobbyLeave(dto)); @@ -470,18 +584,36 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL _initialized = true; } + private readonly HashSet> _chatHandlers = new(); + + public void RegisterChatMessageHandler(Action handler) + { + if (_chatHandlers.Add(handler)) + { + ChatMessageReceived += handler; + } + } + + public void UnregisterChatMessageHandler(Action handler) + { + if (_chatHandlers.Remove(handler)) + { + ChatMessageReceived -= handler; + } + } + private async Task LoadIninitialPairsAsync() { foreach (var entry in await GroupsGetAll().ConfigureAwait(false)) { Logger.LogDebug("Group: {entry}", entry); - _pairManager.AddGroup(entry); + _pairCoordinator.HandleGroupFullInfo(entry); } foreach (var userPair in await UserGetPairedClients().ConfigureAwait(false)) { Logger.LogDebug("Individual Pair: {userPair}", userPair); - _pairManager.AddUserPair(userPair); + _pairCoordinator.HandleUserAddPair(userPair); } } @@ -498,7 +630,7 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL foreach (var entry in await UserGetOnlinePairs(dto).ConfigureAwait(false)) { Logger.LogDebug("Pair online: {pair}", entry); - _pairManager.MarkPairOnline(entry, sendNotif: false); + _pairCoordinator.HandleUserOnline(entry, sendNotification: false); } } @@ -602,49 +734,12 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Mediator.Publish(new DisconnectedMessage()); _lightlessHub = null; _connectionDto = null; + _zoneChatChannels = Array.Empty(); + _groupChatChannels = Array.Empty(); } ServerState = state; } - public Task UserGetLightfinderProfile(string hashedCid) - { - throw new NotImplementedException(); - } - - public Task UpdateChatPresence(ChatPresenceUpdateDto presence) - { - throw new NotImplementedException(); - } - - public Task Client_ChatReceive(ChatMessageDto message) - { - throw new NotImplementedException(); - } - - public Task> GetZoneChatChannels() - { - throw new NotImplementedException(); - } - - public Task> GetGroupChatChannels() - { - throw new NotImplementedException(); - } - - public Task SendChatMessage(ChatSendRequestDto request) - { - throw new NotImplementedException(); - } - - public Task ReportChatMessage(ChatReportSubmitDto request) - { - throw new NotImplementedException(); - } - - public Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) - { - throw new NotImplementedException(); - } } -#pragma warning restore MA0040 \ No newline at end of file +#pragma warning restore MA0040 diff --git a/LightlessSync/lib/DirectXTexC.dll b/LightlessSync/lib/DirectXTexC.dll new file mode 100644 index 0000000000000000000000000000000000000000..2cab1dcea5a148712aa14312788227fd61b8800d GIT binary patch literal 983552 zcmdqKdwf*Y)jvF$$&g40djdq_m1u*G#%gM;LPu*3%)l9#fhYumir_89dI7@>;sr@E ziDW#CO|4e@)K;xN6>F>3T8LO>l0Xs&mjI%82edllctNcp;AMW_?>^^DW-!=3@9%m4 zcwatD&ffd%%i3$Nwf5R;uf31|k`{~2VzFf7mrPnLt$6ZZh5Y;df1Fts%kVL4hg+UI zYRgHjS-veN&0KKp;^IZM3x8I7#SO(*U2)@$3j@VhUQ=8fys`M&8;jl3XB6MC@ak*M z%+DWMVpM(pGl#Y>-F|O_`Frxw`$NkSfAY~^hCVgYZ^84#%KJjM$!AMwiG1E4`ni1m zGBgvK=cjj2GI`y$@%sl%oCl}{gho5RWeTc>Key&VjIUSgE$Y34Nqlc{)3v$SR z=F>7?=txJZ)iNJh^+M+3yKy~b(Qk%ZO7bn1d;d7xQpp-Cgr%R){lhJFKViW5S1Bxy z ztiAe*z!etDs71gF>a^72d1k8jGIs^uoT+ECRGf~a=x~eWSUexbGwojm-kiBe&nWwd zzFN*l29RF*Kjf>Jcd=WUsaP!kVxP)@WA>%*yZO$nU0iz=UP;^mhcaLPVEL}Oejzdv zmlojE!eKu2oqQ9J`v1v)6*JV(N5!i4nyPunsZARLqf{+Rb@hn*+LM*VYS+YZ?3Nl@ zUwmoDX8a4atqmPK;pUUntJ>5^;Y*(;lVWwF^=eRpwzASQJb+h-JJkph`KCDLz9h?{YIv=N z1)oUHnfxl0xR#TMch2qk{k7g#JUz~RyX zP-K0`zkvCxnV;iR91BA#G3x?mE#Dz#V-O2Ck{9KQ0>yz}F@C65sX&^FU*$lhp7JOP z_`+G`_@jKM0MbG_Q2Hpjo(nUO#K+dY}_uinXwANO`<{DrNVaqV}R<8|CU z8F8QXrW_*8e$Sc|zbt@^bZeWu;fbwlInSt?l8Z=GQyku|iF-IScy{v@p1sC%H=q9S z#8v*t#8&iiK8EpX>Rz!|9|Jvuc@~g))}AYw#~Aqd3wURC9?t=ItpD%9bL#KY;5j`L zo;@#JGYR|;PF4u`$Em1f0U-t0APXJlU!7@{Ts>}pSBduc)Ug@x9kKjXCksW*OU1^{^XMx`Qz99B{S~2EhCP4bopXi zCe!0j4jYnAe`?rQlAF?Dx)fbq(WXdUW*BuDZD-P8v1y7DI?0?!9 z_N;5JddRKq^=Zo%s@k%fRV}z&4F@0hH$_F``B0SoO&djHIRc*YK7aT;fB5EH{EF~% zdcrrCy}c%H)>o>#1NO+JSL!mDNZBAy;KQq|?vHYO{J+*+(DddtbGHcj@kz(<{J zD|9vmN}DgdWSpXHh<}Qrny1Sf&e1$Q-tgpHMOz=*;|QFhYW{WXWu$6NvU0w*8&zQr z-x}NhWiqLC#MiLg+%*882sxkDbAEt|Wvin3TD(`Al7S(ZI7C5?qP?v~{2#>ra8;H? z>-9wnpZI4oiQYC%LRWm6=To2N|G+ms|E^KPEu!ftCXTl^$;j8}+qk(8iN@E7O;kBl zIPA52g=)JdmvOnEXp`j(-JyghpP`7g@7YDe{ZQ%$I&mOb>0~EF^Lfc+(iaY*bISmB z4myb7wq#|60r`a}2cR+K6?nF(Vb43N<~g9c?CS~$+75t*gd`GH%Y-(WAaMosZ-c)3 zO6d>_T4P&F@vqQ?!+iMH7brCr1T`_{J-_fpgU)2sK)yUeE$W*S-z>Mkc%z>K`puJVZ1- zpitv>oJiEHZ20;FkOx(6Hgc{S$-dO9t!!V48cEZ+!jT1BCQi_YDmLYjY)hBrXjBv1 z`A|0Whr+MlO(t1NjU2xL$rivflBHuip=ZkBd>uI^Xb+C8Lu$Mh7*-CfN2e@-QEQLK z!|dD}L>Y!HJiDxEhiLMAi+Vv*4}OtMww`Utsf%-s%kj%v{KncMqu_Ro_dUQ$VgoAW zDl+i|v)F1|*Ir;`c?wyccBJZ#fAkG2P9Kz;umqKZvh)HD$jIQi^~+xU#a}Vh_jV-C z&dhs5DsKVvjtCCLd~QeV^B-ngT3UXI@oUDf9KRd!tHjTe{hfbVSyrpf6qtj_!`C1W*^)TzxsoWJ zkc-zmm6D5ItS!TH@AqplRr?&0QA)z!VX(`S;oG6^LJOiH38f|!$*Iq$P096XR_)?4 zRqKfz*J-to-xi@nMf0+UFgg##?wpwg(bBIrMT0|It>}3CA}LX$VEJv@w6fUP8CjM_ zzO?h@Ie6*Q4yfbrC=z#`h=`coC&tvFQApv9eyy{72b+3$y%kWfR?+x1c&`=2o>a$A zE)pyDQRa=GTr5`XMhMx%RgZf$4>-v4xT3w{)wU!n7wAIh_spvKA0LpLditFM=_vIs zAbkgtTz~g-ywf0#Tth@tHISj5YLl~%6BNqi^u7e7824oBq*TLp%?_h1VZyBJAJMtMItm(f;9L3cptcxD+bRR zuN0ejKx|g(_1pz{RsKeJZWpRrgBpAhf0r%~t1v4NTg_J@%lNrnq7h0G#L{}KafAgN zoMj0VD9_Vmf{4}P7M`BO5aoG&xMgJ+{W3;B7X<<6KbUQcW!+lULWQLPssadHgLECK4L<+`;YYC6<)IyA=oiAkzhZwhNy4k_jx3~41o zm~b$plUUuq>){zFD~I%H*~elE+3;L8!5$WvJzS^v5RZXT_I!26*-DM#wY?d zIf~CB%lNrHqVWj?{^L<(Wk()G2sSy2OF_K;b>H8=rQ;=R9KF%z=&^S8Ef)zdz(8Gx zEF9p#Pjq@sl$xpQ%9BhVu7;Nxg%VaIagdOlhhzg!kg4EKqz#i*fGG-+7dy0$groIn zYffF_sPbsSo*_<8Jxv#<4xnE+4Ix**o-!|8tWrjjf*sTs4P9{~WaBf)2bp-$6Q)c& z3o_BK^}DstRqZnjU>BeXj`bQAxDn`b$^wTTgl`+yI-4_SJ(}IYR8XxG;CSX3ZQDQP#o=tGI$OUG{!_4 zN)SCjn%;&Z9Xi6Mb;e`tV0hW7vYgaaPc7d;fymm1m_BO}E%m-1_bCtK>!H@6)7BbIKGr90z~vvhcFjtb!!f$oCU z92A<3k{%5}ic?gSV~iBDn^cqzn=yLZVyB{ZP|*~0Ou^YS)Mj)LR zbah&nfLsY z9+*nO6MGDv*lnU5Zw%7e8haY`VBU=9%Ub++1)k`FR?urQg~?Kb7GETbcg43tLj_Yn zU&i9WAIoG49grHy)l$Iz#rr57D1wkWIKt#yt zaxSu%6{a_064;=P`~_=&G%`%;*m9xmE>T4R?A81uja*By_y&$&LD!mN7kpxJOazJ2 z1O^+pYO)O|!V?4AbW_UTguJrb|JP9F=up;B{SDn-GCym{4;8JGwvn+u?qRT1H8QhA zWwS%Ef@QNJd^!YW{3ZPHY1i!cH@z1ey3WSg6&5WEXf(kEl1d+JSgJO& zWT{WPy`%yv3_k-iPv00 z5P;2c^&mTY0pO2sC!>bvf+J}y>gAGvWP(*JxRmVp%s>WeX^$Z4)v`;F*@4=q^JeCk zO0ixc^WTL0;ko^?I;oQOgC02tISiHbN5~Lb)^8Dwry``(zt4q*Qva=7T8z|}7Bc#s z#RXwD)lyhLu)d&u)mUEu$d{>@W&-!F6g0B81ny-<(NQR^DI|HY&hiYbvn1qP0DVeL z9DKu77F$Mnf78c-(}e?LuaM$}!&g#`kl(?y1xzdcoQX?6XKLADoM&>`f6RI1`zXfa z_ZUDQ2FyCD&I$WED!6i+E_PKH?ZcVu{+~9Q5_m7|CA)L(II{RP=8j^{9dDn2xubqP z<^RB(acR27g#C>fRotRn1dVyj?~Mt(JRThntzR^6?xh{7Haiza8%6s>Ds{(6&_{bBHQ7FGUXj-6(-t~I zd$QTz(EC;~rovKFQzOPSqCShIsm~jkl7z{^E8JVv(p_qDQ&ZFn)3)&JQpMBlUfTvW zGOF3%v?FjKi~6+Jy{^|pXc49)vD)TMMqyrTLgT|$gomnK?O$VE<iV#@2_iCn6tV-kq4@El@gs=OQ2Zp(NE>x1evD}BC%4A4MB{cq zimh+abda+R#>MP!R5X?X5)4(5(P5`s`&QM268az;y=`gg3mk>08o4N$uz4{{ly30j zZ?fCdltdplc*WCEuMIZ3Q7t6#8NSFwEBfenwTaM5GzQ~nt~c3^M@7;birU)t=H_*y z<9Mo}4qIM#cxY1}8_>IM^xNm!Ui&cQh~RD4!J===b%@IcgkD3LGO`Y`_^l8N|ggAJ2eZPvKjU%kh|>LGr%$X zrr8u%PuOaJ?-mnYbGx?Vu^oN#+TQfr)_ap(AQFtHYTM;=9jtwkun{i8?@exSgHX@$ zv2)j;bDpLhL1!O^*cya+aa-&RCuj@;tr%ujr4{3ep6*w)Xk4Tcl=`nE284S*G=5^| z3s4c%M>)#t_ztu!v4a_6r-2>P^=#6$Pa6Z2fV$5ieG)ZD@kVCod#v?B>`4WZ6F8&Lz7gHMM+ zap#{&FOeBmU*uBofW?DZ)a7?=7on%{Lad&c|)58<7>< z-)>7>=y$!g_-4Er$)YGGGidE?dx~U;W%9Ze*UO-UeQwtwJPv_|ve5{B?b%8=7d;r_ z4aKq(NQh=j!ElynxEhF~Qa4VQ7VQ!lK;hz1ZqiCT8}D`F^D76*Wy#=Joy$%nmvxrt zB(oOpl$Xe5WTQVbR{ynRAf5F8)@-9q~7b4>W~6xhn^nYJ#H^Mc@Pnn>ybS zxcK~{z;Wjnizce7Y)MwRb~Z|bvJ&I|CyfWL3k}UmjBK5C7*K&30K_^p=u1pCAnHX9 zqw`JcbwC|3)YFV6dqd!qE~N}sV;XG&BUnKmQ-eAD8ycD`Rv)E^H4}z=@Mjn}pj&P2 z2f5J05Ytk)rHA*g? z!{Ij*ioO@>S8j%M&%d?{tR;6jvyLARaJ7~7*?4+w8ObVoen8!mtK%|4Dhi2=)1}#9ja^hZ4Cco-DjXW)6-`7ODtwNp#0iVoiVjHkLxJ zWTBJkU2zY=(t6zE10{hw9Ld50B&9g6K>*4kp62G)U}J%$bE~D;Eq&`5Cu&PCJXCPnAYuN;nN5W2G}o{-W7ig z%}U9KwLHt`h=R#*dz)OWVh3I{T$K&E*F@uaG&tEU)2&(mtkFz6&NZdWBe_QiS%-yY=oDClw)yvqqxWYomB=~ z%o>AtrfPHquBhCn1{&tcS zi0LWk7$8Bl2SuA;iKYZ%DbOGf-$1-o%0P5BY!$29)aTQLTpI*ky9&=s)z!A75L6{s zrpdw=ZE{3+5b!Y8P{1k-A9c!$p8^fO@IOkZ=XtaZzA#L=zAzRjU6Z9mgeHxk6pDCC zp=dbI#E$vY4$=dGD74*xvtW(#Xj^OWoG%T{J(0@yX&z9Mg(skWu#;%~4I$9U6B`a- zHmJ~Wz=2IDSs1fQ!n`tBcr?Ksgk$=UL+g^OKs4#Y%8_t$sYn*#&OCYBht(q4TGb*H z16(J*I9x7}DJHm+K!QFt06_{a`~&Kgyp(vHZeo?N;=FUz5X_Mz6sA-oWtU9(J5t0N zbg#x`?~y5wA_Y?bgp6q9=E{^v_Gw<%da>dzL|}9u=P7^B9lpBcd>EsoTB9Q-SvX4e zHBe*V;%eYR!wR#iPQvtd7BLY}-Q%w>sR$~*$g(<+#0_=9Q$Y(iqEB))_#E1hfrrh& zN(Sf}?a}_F`}Zh$ouGWV+HU#--ol9n3vJODLb%2={;c3KOoI3CL6tR_;}#*Z;%!8} z5RMZ*&K+W@5jVDrOQU%7ic5EVAujL58~ZzWBb=MQ5SQg|W^iV=IIE}je8dZ*GTtlB z+PSzqak{R9#cu)^DKCNJE%6!|gwV3YFGaBJlN`xFIkB(C!!M>gJiSaJARh>T)nx>w zJBXMwXtJ>1B8fO3i7n^BSJ8*x@A3Q5`9M4*$l*WcG2hcd1viN6ONwW~2m*sgT+w!8 zfy<3T4jw$E^}Qf?q<}Ttu#jVssd94`r8TKP; z8RId|)?m3pTMB1T?U@gt_K^1)|Xw56%yidK2A%-d`{a`G3`*W zKHMNtTQfc@col#S8$fRiREah9CeYK-Jb`ZeF3=SNK>u(6=#vTbNhzRfM`#d~>x<`g zC~dJEsndR>*X$bQ;m!k!jv!C*lr_oQD-Q${`<%Gb}80hkw%*A3aZ7fmRAZPDNqswZzE289Lc$r|Uy;KcMgoDk%N=0N=Yy4A0HBaM9T)u_&e1$huTJSRLX^hPJ zR~%}iWn7c2{%)iCU{k7kN8l1FEhkH*{C!dUwH>{ zsjr}5o>50DP-THl2+E6Jdsk{KDILbIeU1b;YU(P?P^rJf@4@&&dnAEqn?f6{G$Aj= zIKoBn_L4>Xvp~2vk;1Co+Cd*C-1$mqTXvUcB_(5D6NkOnE!+pOKFd}kOR+mCXZrpn zaQTA{@cTm`Sxz$Xa%~ehLR(e(4#}e432#dAL;{tLbgF=k!7c#EG;#y zc+rY>EZiMoE*O)^7qB;B6#du(g1r@S2)Qk>-~Ha)sagXwwdd^jk3-9UC|}-ec~zO{SLn8enk9dc<!38~(`GIFxw#s~vMKHs{SlEb0(12K% z<&Su+x}8*Ub;Bao2J_p5?cUIbS;0Mi+dFFL)2!e+*o}qWk6hUG;^p9F39VA!uYH|oa09c$rUC`z{IK=lj4=gW%96_BwE1SX(cjr<6HQVtCM@fb9{LtmwtLs>B)m?( z!G=5Zn(`gxeX&>ea0wZi^*&aSn6;l-2VkKdcv{`sHhrap?c9fxg>V1MZh0P@kaV>L zCdz>W{)*j^Nyh@uST!NblgL6bd>Sn3p=A#o8<1na>j`7*|AE)~P+TOT^Q?k$e&8#+ zCAbt@_s<~mIV0DJHJ(=J=^rz4x#oWu+URU=BwMI1Oc*!&K*5zjBwC+oOajntG14B{ zdf1G!f@f}{M}7Pm&_g~al5VtE(8cQDBw{$n2VJ?)|u*v;n2A=>`HM9{paEH^ao#*!AK&`4lGlUPzHbdi7E zXRP!Z!TUh1XuJ&plJQ?32aSN~+z5TJzH~ z=W`|dW%B_V4!&42RM#Rjypx7IzKg8X_pYIUKa6^8aXsG+Q4(88QH36pQX!4`v{_;QmoRc=bHSxZo~j>@?Uh z^aXbbqd07$FSrY|Zq+V@5Z2t;QzGa5NO_6TioOkw;H`41;6j{*2;{v1tAagZiTRdageok#R1XYcIn>nAT{b721Pne2?{~ zv3rie?$gQcD`h?!U%~Rz%RH_Rg6E~17-1|K0#&>1;~eGWPXmu}0*bVrDx=sFCgvifac<0vLk_E>;xNvt%e%&sbBu>_N z?NVjI9RfAb|5k7*#0{&Q^RXS)jtDn=Y}$68t<78C&skZlcoNBKM1_AU$W?8N+qjXd zhM`YyT#_f5l!l1h4iueY^Sini2dU9xzMY6|+;)Fy+my(tyA+xGPDp<3Ggug)diJ{M#PgXv33N7OpVGHse&8tS)AIvL zU;6Z5GsFfF5&t{U#1rbe?G)>CsdtpWNBiwkT4Wc<<%nEJ$Cl}551VOe_dqgQNx3mV zgjtZwtzxoGg;(2@ES&Ojx^?1BW9!6?RBBLXvRQNMPVZJW;PCf=yO$W0dD)PWfCzh;mx1_~RZoR-j^7uypfzxs)0u4!_#(%T_Cx+%<%>+V zDOv%RWZ8bq1=E@f;Jgsag)4%s?L!A$6q=Q+=8LRqs14%bmD-(&Qf-n$tljJIY16Q0 zu?^Fp699mKxr%cgy z#m0ezJ(1~IQtg-H|LZsibiW(~*Z#m+#sFdh*DIhVE%V)he1xtJEr4&_oFfBr$oBVt z&ybykVzT@+1axitOvrca7*P8YYwW+qLpG|k9*e5o;bo27^9aKUUF(6~{{YneW~osm z3lGHzLvTv6FjqpY@1GiLFH+An-v`QLC@n$hKmco!Kf;xY#E1pMHqrEBpfolXw&BB@w zL_i!?Q((gHGDBt_CM)|!(g#yChh;4@OXhpE_3DA$>Tw&@Q2QD6d!;I5gLhI-EODHk z^>SpT;!yz&3Q%FQkcKVF;yOMx7niP!xR!n&O91Yuq!q(ojWgTc4(X0 z;u+kjW;IuWJEJzA>s4^)ncz;Jw#np5RohOkM8|yE1{^^CrvnU{`;KJL0&Jg3xKwRc zk!pKQwSB0c+yt*xgD@!X%jnIADG6QWl&&KIuIYhe{Mvfmd>(rZ;Aq4j0+I#i5B1%_h$O}R_XRJ9pJ*p=2Mnux$iqNCf0 zh}iMiBU5d+6{*Qi)wMXcc87|-Rp{!9QCW)C8K6Ff>m8t?6NvdxJU+ZBMevonXNC3} zhAq@)Ra!qrQ?aAAqlaC#PjQ7yCp1=j7}FV))zF_vQETiCh}MKd^kfpN?Mo1A4n2oR zO;?DgL^oMFL$sL}!_}=KI0;LH;-FKTTo#JH8|t5Q^N%rGt$GZD^8ioj-+LKQzp`N^ z9@1@~l`E325LNS|EkoI9?5E`>j+5y|jJsY#^*p$l3LBwkg!bkH#&HRJI{zHcKQv+n z%R56{CR-Dy>w&DqNqQhVQKScK384**wT{lRSfkA{GbdhL>jhK;5=+c`HB2Jl=whZj zV`n1@SvG?_HSYQAvGlD;g6%^K(G1hhL6{B+44+?PYeRIg5iLWQJ`a3#2Ex?97wlXK(5 z@F)K!mx|2)BY*PKg)X3Gp$fQ4tF{Dn*9XuoN+jK@Tw130*=dl5|8zIlQ2I|Fq9#KB z>DfRSMp!f*!Vu_gqDhbTjx-0%MZNH-^h9Q7yR|+g^gi7~Q}$bUbz=Dey}~QpU33zq zhbY{ErHAP3B9L2yYJ*!V7m5@Ns_QKgx)-fT=gJHU;a8>KDsam_wB}8aC@<`|ElxOu0Z?a9b_3A5*#FbMb!zOxd-KuMg zSaC8k)626(79GWyYnKS+A;1+=5`Tu$fitbO0r%~|5H<86{4p^%WTOdL{~8Z8K%m9v z+9(=MmC|uG9Czu#DjGh3KE|_(e}yel_u`H!h8K6Q$2jnR9#9UCTTf&`w&8L+i2rK0 zx3r6^KKifH?Y2w$uL7yf5~-mJE#l4;R)5M33cB28Vs#dM4|gB`7@N$T z$S1QQX;AlQhZLkqY?e|E+388x(7KNFt~pJYKKd&BvZOPz<1m@!eKZ= zV+l4Iv`wetSG}d1y-;0~xXED~RBMdOW{gXlFOpqJw_;Te%TUw;86)ZaF)*AtGQCP@ zALzXu^nRmURg>OBGJ^woYTEX}iM&^=Yd3d9wgPLPxm~Xjd2~0&SuTi4bN>WR>k{P( zYN%1d4)9)A6o^Xtgu4wW^}&yV8!4|QuE&VYkRw*e){OlZ91EA_vvd%}>KR!E2&~Ph zD+LW#RPbXz93-^U)Q1M6ep{Dp?Fl)CxZo8E`s^a8=DJ%u6!*QN;VC(eZ)EtikL}mT zky{oS<9K5#0mrW7KneFD54=pkUKit?&R)8(f@02N3qEZ+eA;JYx;3R0$SiaGI&iHK zm%%|A3v=ZqX$C>M(;fNY9n6xw2eMj zjtIS|XKU(%%X`YzeG_CGTuHs9JA$u*H50I5W*;ku;cjGaG0WELWszC>%cncbZ!(I% z%tjixiOn9e<5J%2hNm^sh!6#p=m^=UXxlTq=HWjA^jwAv&_XOsDyc zaGLkS&3kaCd7Zk~Na}W@Im&b1&tuYew-VY5`fh8)0Y>`PbL|N;?NkW%Eh6+^GUr5W zh;c08CJ&|q#e?8iXUS*%CZs1RdlT-I;JR9tlG6c-82Kk!Wk73#Z+%{`;@S)b+3R*4 z#N!~!XY2Av^2cZqqDjSM53FL6e&Ox_7fSZPe(7#``i`&;!?ZCK7zenC9I%Wg$7&y- z!U+MK5?Bny8ujtS5wp8|L_mk^49^O59`USzKDU@>1<=2VQbbAxSFAqzi01`xhG?+! z0-f^Az<16IpikTY7ELrnaGVKk!z{3zTuHP-C6JZm83w6YF3;|Udr>#oFIQiD(Y z_hwumg((PoI>xOAP_kiOvNzSFG$|u(BB&7?7Px~khKL16{+{8HKNVSf84pivLE0)jn`0B9 zdazbjW%QNn3pZ|hbXkM&AMa=d$y(N87?Z-+Zf23|kM#Q;^sC``6;d6t167w?lS0)Z z;rs#uUi%!_!hYOAn#%f-i?*rxs&*|lnioMe`&cwigWQz16OR<2r%6+YSi7%4gm@*8 zH{#Dy+xFUVOhBwS4q4VZP)#gn4mUXA5C+j=XARiJXxo$RwMB`>`7jz4V~6aKzYo&1!BlJZ|22F!KZ;^h$0KPvZ^*la{))Q8lwqO()bA4(-x9AJ1kHbHBp74F3sI z`h4>)Wr@RTj_v{jhuEou!P>y#TtGZ14kwS(aX5_`2FKw!f*8ZnI0Job>U+?4)-UyX zdEJwVLYVK^IiG8fXt>Zo;iI!83gJCKDq%mlO(EysO!~kG4$#N^bozjDFnHgt!+{dg zM=di9-nZ)El5>wh13$l7ui!^%ouIYAq=QS0PP|>kPQWXSo)oDIeS9U0D2J-{auEtQ zZ1xK``qbeG=5N7c;9xS(C1d|K?f#0CRjuiY6jLvSkFHYnvP32rw@R;|AGmH}!~HEszO~0j@@zz2NbWGq3BXuWIip)+w;ItB{cUL;Z#olA zh5Fm$jpz_WJ+L(?S~sqw7L9!%RJpVy&C9@h4A2$o4Fu>cgf#Xy(A;~4)LJ^M3O0pG=HzsU_ z3>riE`kfSm4$oq;H*-p6n;!U=2(9huB{ zgoUT~kDNn4y^mW8$Tef$2G{5lZyJ{vmO##y+z-sS?*+SpWJl}@$`P^qFE{w6X%$(G zd#L-VHdNqlK1(Rt`RMtfrC8d0bI_JbUqhmO(T?lz)B5d{yGnLKm|iic#K z&&)uK0rJp02*9g_JoK{pvd0Xxo4KAxK_I z=OO*AoU$>?gD$n5xTPaj1Xm=S9|M{`c?hA7I2W&=_aWHjz)1_oG#sXi)N#CoV^T33 zpeZJCYlUN48SJK!hY<_^j(=$&h*T}&95^m^4=(a0$%|(F=0oZgt{;ge-f9Y4vE)XT z*5abrtB`apZ2v~IuXFHA$mIG9l`yKd|QWe4V*d(@J`Vt$&1`^n{C7+S)~|0 zP4nGa2iO)P=C{PMHglo~*p?iCq^7>5%O!1b^Nu-A#Er!mpJm`m2ulxSWmSbCD(^$Tu?qpO zN37|9O(4nVLe;=3{MLbB(ZGXYx*8Pf&svhJZjVV^sxSshWV(RN&^f3_FA#si$UiQ& zt-+v!n%@E7&Nz3XfpFe1%zZ=)+b;c@LRB^GB|x(>-dzGjkK6AFYS?n$w2bw@}3|d zS*p(llCd5)k(^w*#AM)Of!_+;6r)RbPX+L+%XVnC$qsL5eVOWdI}NAufQo2vK$Pk{ zF;RM0;@Z(|vFmU-EN(jekQTRB_I)U#BY zSw29r)Q}UHjkd*hBbq2>Xe(dHNdwND`t5VPi`MHhZlA+o;Ua7Oxa+E5j3VaStno?M}p~ z?pDIAf`3wnhp$H+n)JFhixuwJ&dszqe3Y(Fw{A20e>eFt<2;!g}ckIP! zSbwPF7h-qtb!3tE#8#x=zv9ybFJEcfi(5x_V(T<;J6yONTD!JsEc^v6^?NPYEv!Gt zzrmd6e}Os@<;XRZ`+`G5d&eZkVteO!>0%-EX~nkL78Psv3|;)CtRP{3z6IT>-)E`g zU`Jt8vE}tfltpcOMz`(DhhZf=g1i3GtGp4{IADe3hMtGf;WnYZbRxeM$CNoz{!Q`8 zh0+FY^Q$iOtYIASg*oRRQ>Y;eC%P;>Z@GU+#K?Q9sLIyK2yhIeLiIeJiRy{1Kp~wr zI(5zZ6&W*fNa1aSr9NQn&t1<$Z4gCpgbaNIv2rb3zA@O?EyQA%2NG4S7As#c`CT$% zmAaZS8d<@c_=N@4H6b_nGe~D(@TYQQ+$;yj`z_RT;SA$F3)^6f{B6LC7b?!w$b-JF*HdtB!e1&p(hu+24WPcKG%C5*i*k6}-!9%5aSVC&Q zwJNm~SVU(|>i-_x;0SG|3_I;QQA4Vl4o#IHK{~nZ!-e+3VUu$v_T`0RDh6>24HgJb z#54<+5o}LR9im=E}&nDJj5VoE@I>narwkqQ#?%rgx3|s%K_%s0%+aZM2NOi7S+AlE*d`|3F$GgY$2k%g|>@lj#77T(o#uTgCb;a~Zsm726 zX2@AE@ROPr%m8Eh;hcm`T`cj}8}x@YwIsopX6XaqvjtoZF4An*IaJp~jCcBF z*BQzwMo=n_&pAlsxM&K(r~V@;r8N7qGg9NImPc`C)NX%xaj&9%2De-{CayC0`uW4R zbwfn4bK`Kt>Yb0_1P$dCu1H04rp5VnoV6>wd8-{*MUmK-le3L;ZO~@R@ai(Wl3C?@ zT=nLOC>O-GA{&-8^2F;0T-kG+-bNrY4Hrch&b|{)A^0lNkJPI+Vq1CWKf;)WRyI6? zhul`C+bprM_LHK-s8pn{D3N1CaNjgHr;GJnX$N==#k28{#Zm5}Meyo3e$P!W{buW}Vroh^qZ!R;QTRC90NL#H=nTshBM`z!&P1tYC|xc^n$93!LpJ z--J~!ymtHAiuG$F``QuU1=W4+6}az&uv!C`sBn|xIl4~}hkO$b?2%SDgtKrTU=q#_ z;T+q%8lldPDlxBDIPD+44mjqv3+KyX=DXr@`|FHX^@>?9i-OAiD)hs&jeo>l zTn^d04Cu#z2I_O#uazAyw6e~5;6se&Tn=0te7IB9W`evie!p4i-DahY>^}Oh*Wk== zU~y-!=?N)JLw6R+>a&yESju|qHp*7(t0zuU5B-QkY&Ys_VA*x>!&Nk86 zhGEY414yLo7M*VZ%GX3yu9)?jC@4yyZ(ZD)RyjROfx)!<7B+nfUtCAmtO7URXG=$fR{p0(`E!p{enP7JVB@9qc^p^)MgX~# zUbz96QQFDBY2mvMMVu$tVuEGVy8fWB#+O zW?rGjYUUPREo%cAso${{{V0!lu_ar?Bo7Qi1(iK00?` zl)l4?JL5bDqYI)Ll4qXQD;eMy$$zT}8~Bekf&UnrQ+QH(y@#`5SNs~*L^ixERi&H! zlB>%f`;J|NZoBB*u0z(AE+clwPa$B%fmwZRFrOTQNMAbw4q2B@RoQyIDT$W9=&urk zUV=Bb@MJ#7h=*Y?&LbzQFt!^owtelnfwN5n;>5Z{pfkUdHO798prtZ1%%9UpV--F#Hr3l6LOAXA%yMXLm@4EaHFBZO{|Jboiqq69n&4_H=bc5VdFNep z-k5hd`(9wFz+`j75hoko&SGM`6JqBu`Q=1N}^&?>nQ(wDM2k?EnIKG2J7;8W#JQxv|*C-{3^E zrUt#H!{B%3Pxv!N_{jRhR1{-XHdKD0tU^a&xmjH!S_BOk@Hz9_|MI={eFgcVYZguW z5&S_nD<|Z$4T2d^RC;CBVpQIE2u()7pDW%K579S?F>m);qrW_qNDmBaJ-;=`MTZ8CRaZWI) z{>$)T{l7R>e)C}ExejFiq#UY5r!Ks}G9N}T=y!xFaHB~trao;=)i-{S`V#+EziUs8 zNAn=%f&ODer~yF9WlnMMRxC`H5RjX&B+cQ9G)q>H|6kquigd>lq>l@JyGkxa_sivI z`;T3Cn-%NC9lw|urXwPp@V1*{KZn7~?0#8zW+#>dikO9w*(Rzk6tlL80-=Vwuxi1T z55I5=Cl<}ZiS@H-us{-4(55*rtyU3vs=?r6v1Ti)AQ&!}0rHsxxIUtC)?$D5imz-*?as z2J=%^LS^1Oc<^|0@%=&?FjIzEX)NIx2~e^{daE-KsTvv^c8PBLdXFzOA-BrXZNJiw z!;ajzLq2xk#{-t%&rpC{ipKo{v_@9y+e%MVoBPS4QJ)OygahN)|IUY392BXldrU3g%dadwdqPZ4d0ju$StmpmEXSx3L|60|2@y)@#Ei zE=;kte+5&-ehesi+Cn;W04lkcWSU=1n=Ear#1^gatFD!o1;oC_cg=J=B`($77W<`< z{7)v^;6RXghRL@gS+~?aiBYMTA;&|N26s6guMLccs^E*w>Ei*4J<^taWVSW79GHVo z*{ww80)2~~wyg>suNUDUqDLo5+M{zxkrncr z=Z7?}BNmIZInjs*%qq8W-+?>w+EmT z0eKU{)?UwteeHF4_%z#q69mnz=%4NcaVH|6>oPn*zS~IJnKT~b_h*6AYX&?Xw#9O0 zVZh<;0322`$BTbdjQ|WklPl#Mp8CBZ#&l zi8gt{wN*!0tB$Z%B23@I@sBSSD{unU;vYXBTi#<>shq!N%FBolRr zSGyP`_WQM4i+$qh&;7Qqr0<8{_JuTjzlCOf+K1llAJ?>$e~gQMyx0Jy7O^pKxi_)| z+l>bnSK=7MQEI3YH;ayh^*0-P$v6=Ycl_qu=1N@Uvf7)~tXkd86*h*g&1!aYrJX^W zH`JEhtPa5|-hhnDi-xMMcmedCq7@Ppk_C^-+h>shMxISKdg;+D3ea6lsJe=2C#hO_- zVc40C69N@qi;2r)}qMw;U{n_hmf>m1?_-iK3+Tr=YBvn z{5*nAHLGWVVLGi!bH%qxv&th^3SQutWV1y94}oHEeS}SE9-=f)%QD$ZX`X}(v%p1g zc-N`r5C0=-nW|Gu+gGOaTEmbH>WU#ROi89cIbRp2yI|_ml6_oiTAHtD2jPDSIp^co zVV;v4gS;333k_T^9lPXhN|XJZKaFq2-UaM~QKdF0KY52M^5E2$`rs)|?ryLu!~yki z`oKV0_d~L3yNvG=l`;!1y{*(ef#3|Ge}PG=pK#^Ec+M`y{Q+XdA&itdeiFVRyI&7s zj(Z;=wvHJUSIS^tS8gbp9~mlx#BgH6u~=?w^RwNf0iBY(=V27nzah${BM&PJOFhnQ z;47;m^iLr%hU*3J)r^79tm<8mF7dr6_5~I%Ic-?HToR=s0#JGQRk{jYPhUlJQhIq; zu%)FC=O`~f+d0W8_jK{8Q}{bhO8e66QXGMPOP?blp^uRr1Jd9yloa zHjyRIz&t-AL12C0(j?)ikrR%)7S0YljB9iM7tnWa$cYCgVa`*<$$j3?L5FCd{n;DpM@S%~ zgub!{bHHMC7bXf2$(Alf)aaJ;3n8ve!BTIe0{8JkXM6{;4JUKdwpg}`qp~gGi-xHY zOsl+QPcqeZZvD4NwDx!4hgpPtvDXcPVwR{n)Y ze^}`cU-Y#+fcK`X27p4=E@JsmKSEGf2f(;$Y!s$j(0%tD2wz_GVzLInpC1Nrj%cJxsRPV!2i3G-W+A}1wx1B= zo7rn79I6Q`hZy)80sJodR)W3D1RE#l>F)xt5qg>-VDJpU^Nqyd?~tNn&?uSj#vPWQ(*Afk950&RZ@fDLqTFV!vTK+LYppm^8S4$FB zXN6|rVROGBYuGfPXermEey!@As4C8j01F9;-w?A_!S-P zAOsau>eoZ@D$&TjB&iX1f?PuJ6ZF&6p@Tkrjzo%({^Q}XPdNk^$uivVg5fS zAH4_JO8s65y6hH1vXiNfXzc)+oicjb_O2tnUxiwG{XTLNKJ$CZfceQe+h zP^GH9Wy;OMsakkDSywyz+fEQNn$UOVo&i+12T%CUxn zWrZcV(YB+ny&`Y`#q?arZe)DE8HhQ&bcQt0+gpC1jpM@ha&Q+%+dfA`xjm!3l8p8m zcihsCW663Z%H|@BE=L>NWZSvcR`0NiINfCqoasJ{Gt^IkLdfQ^7 zOS@Z(5Wu|24H=TLgJ3Q&IOs;^JoANm3nuz_b zPAF`2o|EdPbdP(w78XL(xWh9e{chO3q^b!YDVu1&;O&H2EqzGnt_EU#z-s#3bhLGhSA_kv%yr#t!9b@ zA@lrN=%b}LNse1_r0XrN04Y5UI&ovk9yZR|Ao{HuIb-MrFCs2h4980~lK&dcDM}4G zYyM^myp{ECY!>+-i;{bNE}SRr@rlWJyo$LjHY{YfK%VY$QluP$LcLLj+CPI$6-cGc zv$RJZQeTPa7h+-dF{;)EiNLMBnT7sea1&-zji}u>2GLLMMs#+!xU`L1%>}UZbl^gj z4lh1a`2t#5m}=#!R4YGDwIcN~+0;pgHFasXD9B5z>vh!i1HGb<$Z9n{!%o4 zKp76RUdmqp9=T&^iwboOlT~y2heO{r02GVqVOVBWjBh18kJ0j-^B7^;%NA-y)I^+a4zxS>;PKc9V2E#9l#lP z9}CPpp(moZ3?G6$2;wUfbpN93SALYG=>C;z{t-0)lT`C(rZrE)9y@w?`?9IMeCf2; znN3}~iFbuo+2_dyOk|qV!EYNuSb6 zU+DqqR$9UK(ca=~#F}Ye$7&x!{x#FS#a3A~c8!b-7tZ~$OZ0TBaQ4ThA?y*I(Y?TZ zE`3cq8S7-Ii=k6-CGxq9MHw6K76qKWFcMw0zvg~iB|X65&WioH3ZQ083i6Q%fmT5T z2LtaDroBK6%q4BPHK+)$+Qdw|gM$vroo#=AB{AO(kDrx)!P0t8evpsOjk^sxBf&n8 z242EE4YmMko<+!PpotCC4bs5VM7V6A$!K6s{%suO>ilyCZ(we!fr|zjfQ{0G<$2m~zg7gj&B@J?1g3;X9%3XpKSPMvrSoj6{~mJ*P{+u{iPdqUffAgume5NrEXN}PgSbOP&?2y5;c_fnffa6 zl!cTDVzqln_7@X`V-&7e0j;C=Gn*O;RF{)P_oenYawf|%j;`b0r4g24~*%H)0 zAplthGksgG7?t1l6y|p@4j?S0^FP?Iz4jH`x%o#wB_iJK3$w{uHO$ z7=;jyaz$#tk(W^Hg=T8f3pN(3vF67F2!$P^vVrfM`B$S>j7c{6qAGt9D~CI38j<(a zu17asEH+X0SAtklyrw-coBPW<0z7HX@!`J*D_TB_Msv>LnznxA)SehG< zAP5Lb&|q9p1i^r$Ljv8<5mXQ|f?-@38C(z&!DYsfCPJodWp-y?b(|T;aU30;4Uuu_ zPB04~5I|YnKxgR?7t~P}k$k^Xb#E_8hv3ZnJpbqWo`2x>ty@+1)Twi-PMtb+ZWRy{ z>_KhHzeSgS79>!0zeJb+gs@tAqpJH&LZA}&g;Hv%d#XGs5Nn)J`3q1F3$t7dOHnU`h9^#7>28n~x6JYSlK)Z&2CS=zz06;Lv`XilijZI(0 z&4p4_j%&GH6x=MaRPVn_nMu5Rl>)Af6>w!j0cQdRxP$jGx8x{pyHoa1(B0=q-=7_3 z5(W(;(f%v;qaDh)zm2h)vlPANIbjx!YR$eiWe=2)6=3NC3hYG!cJ_9y7ihpSO=}x~$7;Y>WY4t#AHc`q7~gL%u<4&>$(DJ)6ymtY zR9O9M7n$J5WX+K@-M=zOrG1|vc~$$KvZ5_>qh z>M>RR+Iy|?XY2CoD1eGt<*z1{@*|R0m0vFiB{dEy-xDi;bVB*A6P7<%m%kuZf4(-C z`X`m2Bh@W&@Banmm%0xKHVTdWc4&xvS&tEmIS^#{10kCX-xa`d`C`}qC4o8H{m)qa zb@_FIPptk=N;xI&6_QuiUr=aY|5(AJ38CCz=F}UMJ5=En27jsneewy&Jl`w&@fsqe znAs?q5pS^0?CbjS*ECk z>bSK*l;UgH<*!~k2L0h7awiP?0>3H-|IS4CUH~bKCw2f4Xj7A+Ne8$j1}!%c+R=j= z+YP^>eCvBN1${pWoD0?wzER0<_uU#rkJs^t)J$$96r;yO08$Vi>;NM8{yZ6);JZpg zbC{KP6B2@sAd@s5*`E6an{Ag0(6wDHQD6~sS{$4zIZI`l;z-UlyiKl;KNe6^6no%} z1*=thxUQ&z)u?kJ5$jm4tY?eETN`|#g0JFlyq5UJL9=ouvXu#+8F)ZmrM)XSD-@ir z@_}?~eKKPA#{tMhQK)ez-jUQCYTQD|roxj@f$>YhyHLXeQdq{NzOKm%ijv$aL#9St zh+yT&ZjETgCiO z{6(qoPAa^*uU+AL2nqYoCzD^5I+ z%YLIxu8lfZA_dy8e~XADyTK%~|3P_G){8a&6`(jNWL9f6x%=Y~V*EeFCdVy=Y%(PA zU&D)Ycd~+#z<-T-BLCau_~?Te1#JGe$+21INUVJ#{~zs$|GcL8uilSW+U9@!#Tftl z*cHBq5d0r}4w>wVZ;bI@0IuyS!g}_0tL{({#ib!`A5ca2Ryn6QP9p0+TLkD7H3W15 z^=~?>Q$tk~WaNS%Vs(oY$j~P8?VfQr@3Vgpjr4W}$*PI?g|^mpR3D+yCi=(?y=@xJ zAtZg|v_}|o)~wIaHSa~vgC)c=1oPBuzW66VXlbqJ4*CG zmZ&2K`b47LSj-~paQUH{O^|7?(i4Lh^TSf*cHCQ67uduWmxU{=n9*wknY_0WUIISDm7+N{PuSz%W;A*QCUl>=wCYYf26 zYqs%i>>vr1WPScqv;bjM9q&;3jS@}CGi$JZc%|@|`k}J@LT9_oMzU`E&FXVq*C3TQ zKKK39th`g>_ybnMHIDZYlGZL%zq&n+BQ%abkekABxr9oxuCQ@DQx_hQX!Q}_AGMnF zm>`{u=b@;o&QHiHgBoA=@o^5H6}!q*-XuKBHJ%69tr0xY=iVVCc#7up2LYt@InmG2 z=fW_=HQ^_;GYrcm?nx3VaX(jGMBTACrg@_v=5ls`Ui#ScRm=bfiz0tfLS<+qD?{8$vdN%XuMrK9JZRC+PB? zkegm_(B<7|mp4Yr%W=IWO@r;cSQ;(U$w$D4Qb+ zm@xXxbGDk^Eu3{-XnNmazj+2g9@eSiNn`pjw;kM7Ak$>Ty z=@+nd_`;?}NL4&6;thSzc>LdA`WA+Ncmo(fwvRR+^XKmNJGc8*)EXI!(tMGlnIwPY z;=3B>Uau23!Y?zukt1xWgCrUcZi-~>sTKL?bfFkHL9{r*l#taFQ@ZP(j;Z!q$CNu} zR(92t+t*!Dj@7N5goJW;9VraHqgMYO{kv{VH_!=1zWQEdgVT$6uhsLCVKn|6YSOwm zZ`XW&$}w+f%5m;oruRJAOoP~EY0xzTa-Gkf<;z3PBpcn zya;vQ44EQUtD~t2(d32QPz(ndS&3i@r(j5vvxp_lEk#p%*R1TP3HEF^yQ%$}kPz(K zG&KmurdYRdge|@AysM;V)BB_+jv}mkVQf9xTsxZ{81ItO`0%F~87inKTzF7AA@1JN zFB_y^s@TKZ=*FHw&BrSJWp|i=8yHk2+;6Mexo=}CPI3tn6 zx`S@P0R@i?XtkaG80(f1S{QU{;?`u-2{MU4l_wCb2!szpsv3<9v@3K*f)P({z0gt! z=gHj=`o2QJ`3Bi3nDT_09`5lz3{l@s>c6T3@3g3#TPH5?3$%t)p+y?&TB~TWq+%R<(D<%@`wK<($G0LZu%K4h zx3E^^7+i#WbS>4PLaQa&8(O1F-0X9{Ve?j%2m&V}vEt*>eQ`cYWCj1RT-E^oPG zQp6cPY;-oPFe5b|mok%FQM%xRNV)i$OM!wBL{q5gEhgRMxYgl?uV+IaNRcr$9zERA9^6AVxDguMLosjxD@oOF6Irg{X*5ag6-JJKCf5G7z2*;f}Cph)a0n#_0T(n@2cRV9@slTOy} zlxK2a!}9nZwrt6O&2{3h4C5NE2$%cUhOvz63$EFZ8%FQn7{(H=9bCRA*#4U<^S6fa z0N49m`Pl7NbG^m&^zTS}$}pC4z0KwMybvQo$F(++n;4W z2(C%b8OATT4s+f1N7A_N_!D=oY3w}kd#)o~*RaFE@3@}-t6@C3mizPE*BQoUtmrwI zQjc;y`ZvRv{GwsJ$aNap`Mq5E;O2apvR^Tb8DYLYi{Ob|OWdo5v8j$a)Ema=2E+IV z*UU!47{T-TxQQB$w8_qN*W@l;GrD!3+@q&^axXR!@7?E=$$k4~^*eQP{{g2B95i|G z;M0dp9(u-^lZRywKWp;YIXNTF$(?-edFNj+^1_QoT|7DYZ}gZ;#*UkO$>gy*`qI4d z5}0t=MD?8SDbVr%_3xy@qRs4=pf)I8>{SP*27jc^p$QcnS1z*4IGHwUJ+m0^LpUa_ zOq;U>rm?f8pS#bwv2{O$G@LEHf)A_1Nn7MQxYlEQ-ZxZjcCagY?M}U;IgG%W`+c_4 zqzUQovwtR0;x4ItB(;O2+IMp#?u=bz{}RuQ^oX3%2LgKkl0NFuJ#ZyGCTGIV>tj1= z9wCFhqvqR$qlfR58C8iZ%tMeYKj9GpRj%GDE}e8k;o~2@sNyrOg@LG+_1Qm zLqPG>w?Yfn-1wSdT+4MUm+T{=F14YE`cW~cu4+pXYon2Pf|Y8$#>?%tA|ZzC4*sS3 zvmvEQ`?_&*Fp>4AzNep)nfL|jiVx^x@MiHkbHwr|;Yt2OY?QF{E9A*2KE8)g>R8FW z9I&f4NBI2+8F!w}&SVf!`yKzB2zA)`_a!Crk8~zs$9>Lb%kBDZi4A34hebv2*E;7Y zPTI7pBg9Lq@&>-dv3jOt3)JFRy;LpYZSmzcvLA2WS7zWkB1A6{$Lcz>cltP_Fn-hZ z-n`l+?=XfBRIdsUGX~`R7!Ud)pa2Es~P$8+sGc4`N9?rQO z^+g>XeERf(LzedbHiLmXz#4s+HXJTM$m=GB;R`X+*}0eqeYkB9XI8a$rdmjh2W z?-$qwgoQp(<Qw#y(Lex-!p4!A=N&?CL3)2 z{uDdX_r4*H%|q1c#uzpwF>IDovR$j22@Az=wEE|@K&?3yTJ2Rx%^bY5l$cpfc%w@h zgDB%oRmR+S8RF9D&}Cr4`TQ)jyU^X*ce^*Bz4f)n5MTb#9qHcOcarPRvYbUvTmkod zS0vEr_vBXEV9xu*%&Cj+Ttt?fI@xaEm|0ghD_R?mI8MY<(LYH<-52OTlB-MfS3FC0 zwl1j4m>NoF$BgEdX>8F6hy#ho*NzUYhxh(*VAGcOq39Y$3QJUwATm* zs{Tpk`J~2_C$|#Umvil67lru{Ty)?J-RDGFgn|BHHeeLJ_e;oz{uOI+mc03tV7xw! zKm`7;P6$~1hkq0UygCu^<3Lkr|4$rjd(7Sjz*R8-QxXAOFYm?t7li)`kljDKNyT%9 z|3J{JN$8(V5;4<#m)v6gb39y0=$~fqGm;7P3((j+%qCD+LNJ|l0O3UTVv3#Qz7B(3I#ATYw`;8=+Na|y!ACl>6_Tg zy}55F+Le;SVhL+yo});(S>^?iWlr{P{EY3Z7t}Kv)p=UM>)h~a&vC0!vlmM3l9ws< z@R_Pk%w0Z^kg5|)AhHyw;J0SR;G>GHl{(CGJt(&l*F)cNslLQ|&m#mEn2Q@eLUrdJ znYF+?9yYHA1=Z5h<1I~G-~+Kjmn9bZLn$kfpOq4kIG$NAT^0I{N%I7I1Nb_vta2apY$3!nAVnIQ5;TRclIj8h9<>C1; ze$cvV@2ilMl%`A4_7mjmr!17ktM@x$;yhQS=4B75cp5o)Hl&1$+PhxrUp~Jng z@|MQR`vLH&S**Our1Hk;@_u!-J&?W12;M;nGML7k6chL_4YuL`Q9^cOc&$eaehtAmy?zEn;4e8LU>PrOhyk9H2)GPr zs#!0JgKdwQjF-b=08UK=FiPG_lrLv0Kxb=xcK99$i|T)!pqZ%p|574mx~Ix5HeU9W z%rVs;GzKuTB#3zt4G-gftr@UJLNO>MrGpCM&KW zqOK%Bm+|tl81#z>;eF!ebU`IYrzxDxKHgLgcL?$ER%N%~53#iyrh!*l4WdLN z3-cIn0n4OH^IS9JR^l3W6}LIAGkLOm41S2OHW&*c`!W`IhHYKYqhWz6IxSwbNWa2Z z(PI;fo*64z`3F*ZK~m{Qgv9e)HCNgVTNK~U%6p#jcAjQewLwBcW~u7mGwiC` zUfV{g-#piKQf!Iqny*#ta}+x-R_qmt#oiJtR#Eucq%vRCWqx$U*RA8h0d^h#PDl;O zQbz;mm~cloAyT_7onORyN-El45pLq?kH7!0+i$7;5R>TSk zNq3MU9svlTv#u1-I6~m|_AOo7@H|yo1&v9!rX@+`(JxA+xz@5j_Xbh#WUn4vjpMr3 zMoK+3R%(yLQqQ(ZJ$8pGRqL;PQo&CFcRO%j!MECK|Ab-$+uB7FY$b4oV2{fkOO|UD z0mZOq$YC`Sn`lgeOTy8Y_B&HhPMqGZij_4*LN?{D*JZiS)n#Snb5H8;1xZCgt5Rsz zL47_JyGY3iO1^nZD8NWA@AWWi~r zsipwa1v|N@13Mu3KFlAQ96JdKIcmxjIbvk^x!o$?N>8MM)hd~6XbQaCL8?{zUv!kL zYhIy~t(vQ$LDwAkZEC&PuC;IIg?pMXMGH?j6T(UI%Ws()V8XnF8{ zwRE7xKBn<^bTijBa;UWVd#H_g)Ck6IkKcH?R(zF7B(Wy zX^{8**%ud)hxj5DUqn1F9o89-s*Hy{E6##mU3o_up_0WKZ=k1JmvuCWKyCzog@epk7~Ue$it8Yk=wn29M)g7~6S6aAL_8Pvie6`@ zlJ`(#e>auGI8Il*uoo{x3+!}hRFVCCbh>e@E9t`dSYI_O53$gw7D71xEC=%O3&kL` z7J8RRI}g#zAYVQWQNI zyWOK8u}O?N3W--FWKZbcWW7MOw(SIL{^ewikkWDn7YK<2n^~MzwjF2htdSDKb~$qf zuU#PM4K7va4IZn|8@!*E2YM`}xc*7-q&fp%jOj*}6GL&3giwjphEij}%vb*^Y-V9& zL7G4JBRxrj%iiEYd;ZcV0sm50;}k`-U&*b+HBDnBM4QnuRwCKt!owv4ujwWUWD;of zENUE3v}n&_&%(VckZ?qWJ&U##1X?Qaz+BXD7B13@wk?jdWUyaGVeTH!qK#9zO$zmF zWd9{{cq za`QN4D_{S5Ru^VFJga+Vv$1sgv{3rF?8;_VegT2t^ZVFEG@o}7^0Vum+6X{-J${4% z8mf8l>s8Y~(Io2rKtI;hc*a259`W32u=kST_&JKvh z!|HvJ1ATo98~#9%#lv%`QfmSV0Z1Or%}!{c-b`#r>#=e{dT zUz!c@<7IKHR-LezOmQZp7bzyZU8tC_U=p`Eu4j3I7Yz#v%~zYm2GEo)!A1yF^8hsV zF5FlRb^Qx#fM*+`xI&OQEITtQnPVa*st+D6Gsq|hu=VAPC( zw8gxIm$o>gF09!Ih2_b)S$rCcoEt3a-dRvoJ>?iNQ{Lg= zWFAV{>fE~}Z_5I6`WEt-zCgXK73L|Txpdb*uRG9{bUH&q()Gn5N%w#Dis+#KC+MGj zWJd|TUd-ZZqYx`+@UPTu__P}lRXOL)lv`;H(Gyo<-Xii!?b7qG#o9^DL^Jn!C*wQKC^gcQmnWuzbvcn5v;u)EDK| z%NwnTr?3Xm2#EA-D!JVm&TRn3wYd#}<_bO&K}i%CTbhnY3!qHF02oSu#f2A)HY+dD zy(b%^jqW}DB@~*Z4)WCv;esy`y3nmjU5F7u2{SqXZ^^tnjm$P#bFyCWsP^y!bsPRa z*QxgKxZF;bNTJDXd?+ z(d3AH)Y4f==B*-`5y>K|MNTga>pEx4h@T(K#X?QPrDviiFCL`qxVm@va6Q9z2w<%d2 zHbi1Kr1=-_6}b!fl#E{3_yBFFc;R7*gz7XDk>oMiEuz|LHFT^@^`gjg8cq%yNN=Gv z#RT_TM7-h8J*)@HhzM_g{BDKaofBeBMs230-PNCf+&MuNDAH4=GudR-ph zCR7*U;4$S5=p>^OjYA;B8%AtsinTQbre3!U4Mu<@wTYo-Wv-Sue?oB3CRhx3LPGYm zqDFRWNU*>CNuDjv>-kOAd=Ve6YlZFeT;D2<&u_YD0!H?wGjT9wS`gVU^u%V*w#W(d z9n$r4+ogj0X{@s0y++KPh}a0Vp9B(aXc9&P(bIki4q&u-yu#@9YyY<~T7`ZDM%XOw zCgc}V$wcO-e50TRaKd<8Nu)KsX7brhFw1dWB$(x7^-y@y@Nh({iMoHs%$<>zL$HfR zbuSvWRer0xM?Zg3;=^QNgW~Q*qgdyudCIe5pQvj*6;-E2x0A&)tX`&Yk$rthqfAt} zuvi>6M)sdB`((rR8gw;l{l~1lR%;x8V`S2Gk?rndb=kzO3%V|96nRlLVTkv4C<^*F z=SeLF-zZNdEXOy=LEt&H(Vf4wDpH$Vkr!1w6=9obt0J{+D)KY3sEWMVz9O4CsK_;cKoclrtIKLN3OGX z8b$?IgvK?=R8`to?hVf&rLtL}pG>qrCKH*ya zmSMcXHS2A|*v6Ir4rOs=ziSw)xW3|=wU4s6hVM6wi{CShq3=^Z*W$mE=O5t8Rq!vv zc#~^Yls)~M*-e_ucL03A>jUl=et_@#hlcU5kJwTDpkZA8nPFV_IbRfhVHhQc4CBnh z_^NY#NnD>JG!{o%0e;^F%I~`Xzwd%x1qEy_*}I@mpHuo~74$n5&+meP0|yN*IGuzc zIDZ!m%f|cr>;iHAR=(fFC;o{m_~?RB1sCf`yuc+e?$SKjy?;3m zAh=-`F5v1+w5vHfFLcuj-W4BEQF=r9y_|^V4ZifCdfJOe@RJ-tgH!NB;uO4^pViCh zN8H!kBzIKzvC37^KTf1V`E6=*#fdy9Z{RTm@caFCI=TXx%^wTaa=uF18cx{2P5JP}e4lH%DdVFl>7(9MHNB8v z;GYh>lmZ9&Xf?fvL6WTm#bG)$Sp`O}!F7G%7`%~#_E$E z+4_32nVCKUC;0k8Bu#%_C^YA=_isr=pKS(abklJ3pV))u+=DEI zGGgo#=p3%#WU_JXCuRwTv2-?e;>vOFlr)IwHA}XbbNpR1F?x69;PnFhd0q9A=#20N z@5Wv#8T|_Jg7%>+kw7^U9vkg8S_TWfoni1R+%R7uH6j5i(BJ3_^<=_Sdc>Ev*$lWZ zktKk0+47^-%mIx^;>bRW>U|pv2YEb+pI#aAdc3;_QWZ1|N=w~djs4b6tW78`b&f%`_cV*^`KB(k9( zfei%~8wwN~#>(j|(pnW8{-)V*4FEM8rW3N*FqB|tY{+r_>>``~S?7ShTL~<>thA}l( z|JX?Xuo<(R{-GFC!#xFKex(^x13=A~wS+9jtRmPMW3>JwRq9Wbw7|*=T`20C>L8$> z)In5-5&lr0n)V&#KAS(?rzrkR0_X|(liX3V1&5A!bE+h!;EhSV@MhOY&70|$r{vA@ z$tTB~D=zxq;>{D9H)jD5-Y_RPjgZBgmJ2)O4Rvx2qfXW+vbvb+x9KfbPstoVf~IxY zoHt>|1#oT-=EM>{C*XWc88G2w^zI_Xj=umy4I;AinR7@>EN?EtD>i&2c9kDXk?q*w z4S7UvDSVu2Y-uxqe3#b*`2`>&Eo+M(h78mrh&kt;4f6$~=E4{|l;A23^?a}ht3a`g zAFqwnLm&Oa!|ICa7c7aMD^u*SOc7)V@{H=nVkr|p$?_CWg-m$}vetDjc3L$HBFdWc zaDo_yYR{-`0w=5u)y&zYdSjVBAnkfIDS79q?yW@LcQp5I0u{}@xr8k4oky^QoUEo@F~7VcV%Z zrWkQXf{!KRVvLi4Uoo!l1**M?T(ia*n6cI78We~zjdY7=q6BopGSq{U;8?Zh*xK`L zjy*-l;@I5;JLQ-wTf{$yX6H;eM+;-w@8d{jh*u?auR$CXucgk*XG-&ge<<*66}m`LqL7P zk#l05?YrI%c$TDZKgkP8`u1wO$6cPU*tZHWwy+c>FS*CHS1i6I#dahuzafb!`8Puq zGhv)UC&wL`lCsyWpBQJTrEWhMQ@0<@ZKLO{PSUnldmQvHrEITOdUyS54`*1(Oe9gq zdtS3)8Njr_TSUlW!+3(7u_0$d`G^<~j!qWheI0=p)3LW+rpTWGAri^oiH_Y&f$gbp z>DX^cVhYt^r^0f=OF3HN74eut;#~$pc4xA5Y>W1&a%Y~?mi9?1_UeK-?Uhbfzq%j= z?O)Nf|8j&)`;Q4(v=0;Pl=iN1XWO*T+CoqS_iweJS= zrPOxaz+%Kzl9+-K(})MvwIuVYNv-nv>b zr6wg)Ed6Z(z;uWB1|f?nxdc05im<>*JuIbq>{U1Jy+B~N%#GvwG={~HXEqj)g;zYZ|XkVZllLw-)M6Nbe1 z_oQa7hHSgTxZSuCx9uqHRO{vBPgJc}TZ8@3OBGW}fMJU(RO@!qs(nYfL`vJIt8EP zQK3oG{F}U`X+DdPMf0-=c1CmMzs2}mGAu^!NArbL|Iyg+XN+&u{sRRet_-)Mc5)AR zjqDv#IxLAPD7}t&q4dFtg6)K#ote`9a;6v#+mKq1%Zv?Ovg2bzU8^FrGIXV)^Bzs- zBg1Sue@Mup^UDM~p)(Eok~3@yXKf}ZxEF$Z+lJlA2vBWnL0)WlfLNibA18HZTF^J8 z$abyTnlsLn#FSdlR8qh=tB-~G_by8*Z~iQHK#SZoAq*@cZz9sj#UKIYbtAw3OhtKV z<8~9L#iwrJ5l#7fc}2JIrGzZX7ZU7@^4PP5*1IQAH)Zh6Djxcuiux*>`WrE-0n`Z@ zY^l;-2Tg9}{}Kc`qWoS-OiB4|qzL678lu}wCeJA({WvlGodo6koN@A$7mr2cxr=5KM4UUn}nDtg7mityhX zA1C}tSV|U=y+hhxD~Ty-Ka&)&bd3l1c2c}vYX^ZaLwRI>{~p@Ffu7I$dCZ#onx=b*m!W%_S-FCc)#9%t*csi|2+peq z+v3LU&g+oKansaT^2doCH%;xsCY>RPJ z02o`Az(>akss5w!b17y=i2byJ5Ets5jGrO0w{P8+N?##~DaOy`s*DNW9H=SXJT|3R zc~8u1ZD<`EKkNAPm6@wrMq>Pg(mbRj^qZQ{f8zyB=w}I8gsvdiNsFdM92sEKH|v4Z zG|H1fxvlKzWb}-!PjshV5PA|I>{c!EyB(q1tA#_Q)OKRrBK9egn1a|nh^Jj=?VuLi z-N8#zQo9WEoK0;#d`?d}d=?K(65@*R;gp20(S$GH1x@%dge<~$CD<9^hg2})DU^4Q zp=x^kbW;nff&ZuL58uPy2OVkwdP4dq5223>4js|{r;?b0_74&-w4dEy)86Dc#Q=I3 z6WhtqzG=Y8(!Ng9zK$0(?bj2sXdfil3GJnw)Sha$dRNA1s+W4C)I$6S>kCWCuZ8&G zr1gb?@yhWX(;@Ydu3i$QYh5Og)$%kJ&xGY^*Jw#-M{Y}3%u#QpT30Rjv_zmch#ZeF{Sh3J1@}Hp+C}8oq%9?6k#;1( z&PnUK39KxE{mdw>kO+B^0!LD>IBR7@ku^Y{5M^WoW;eL>74O@vxZT*8+=AaDYlkF# zT@q7{ib3C_2$+yUY-K-k1>uVS~C&lOT+M~!x=B&MYB zLQ=pu>p~0j-(8qeN^BpIg2pdrspbHQ+e&L$H%ZxmEfw!6-qh57l#oU3+X!~vfZbie z&=Sb^obsOvS#`{*_@k>?ch_$Cl) z*P3Vc{o0ha3?%uy38neM{$fHFrCkI&p|rA(NK+mQx|-asmoA`gZCZ7bJxWAzw0CMz zyyk30=tf}Jgl;D@Xw_{NpPrH;JEHX?l9-a#KO{wvzZ+z@yPQ0?)2EM%z0T6{riS{C zQ=<4lA0;=$1tKPjSEmpg`?c8EPj*e-*9lqVeS%;o zG4Gee6k6LJ;-PHTFR6u4xP<2vQlbE?+R^y@%o9n8RMfV_@jzbG)HVrO)ZS-yN^L=K zT`m>T`tgVE|5O~`hhhO$!_jYJQUvI)0eV7eC%5L6f9&D(t*7`JWKN%B}+ zZ|0Ccm3_mYUetOVFlQK5E%nfJ|T<9j0Bw#S?ot{FDP2$-G@0R zqA&g-bz%~L#{cea(^yYG>@}E&hbkJs4x9uU`>A8hD>D+Cfh3ao&r)(bGW)cL(-V@I zqCr1Oywu=6un}UP&vUy$uNx@EiCns|IFybNX&(Mf8}R?@VHoRLyXw9uRBlkZoh-(9 zMrFvi^umIt><6^GZG7tGS}>|#HVFF7WBY^qh~IBy^|TY@5!bE*j1mQ6wRJr z5*qcUw|Z{&*x)AR#Vot5$z~U`mYujmcXSDI8RUTj9qI@vB5^bg@oR zia&E-#V@6-Oy~}^giW4x`<$9<3r2MpJ}LJ!!lHIf9*NekWf)^qX-*?%(NEV1e zvBy~zZ^w&22y~klZA>!1lC+L^@ncC$!HW>_!h>?K5uSW?W(r;$J}r(CYZw+U=CNJ} zFW%~s%!>p*s2(JI(6m=m1fhMM$7x81(Db())Yk(?i@ZM&vgm&=!OrRLdOqEzzxy64 zkTBqP+!wCrom73H2bF~&g}|`{98D0X0A!?IK=tKX15%rD|4b>l9sR8VDN7Pl(BDnG z(EW3#?h7xSk%Ioq#Z0K1gmMZfRs)ha+%h1|b!n+)4MWlgHBl2l2^HubaOl0@>j&TK{G4O5a#I40SxaY{4oKHX_t6t0fu9n0U?7N(J z={{q?NZ5FMNDB77Aa=7D``YLek644!|0nF5OyOGQT}sI6b3F)l!ai!c(y)1!HNc_U z?jKKY&pUex$xxK=Wlrp)kZOBU^JC12&~XbOY_=iP6C5{Zc$FEtO`rRN6xohxmahFv zNkrEcjf1xnzLWYXDX=T6lu8Ku4h@EF?LU+VkV5 zf_$P2E>C!--@Yu$KulJZ)3@ys;})+CO7v0VAxDk zqEbk%zH!rERJAu*9U7F1ZPsM;fF!2qYwr;+%-VRor|N4z;JKYHSS+@@guW(5E79H2 z1(%NIELY__B`Sj@3e{?Rcz=5Ln5g5E+}O~xRY|#)GueWxXjyTPLrB>oF2@_(;R|k+ zJrIUQ=lz`T8`CZd%U8OGbz@!{1^(T;Y59{H_?fnjpR1ls`d0Uvd9~LZjN07>ST)pP{PPTlaoI43@pQJs=y#UGxch8}aoPy- z<~oefxelXu7l+|K-(jq{&|!=j1>6f9#&7s?lJ91n!|lIx*YM$8ORl1p*`l|HM%Iw|b;V;cx8RjNiP%ZZc4=HF*5g{#Ou|N3PiBC^*9-~v`xF0@E zTJ=2F&M)P0!Xrm}ij_w`@(!CdqbHE+h&G7Ps>F2#4`$95IXP*J1n0V@tCwf8g;|3~ zcE_8!)tto%N?Xm|z2pQXU*IE0IbJ8JPEhg(LDI|5avJx_?0tfFa4QX5SFbSmsxP?H zTdfI2bN8;;P(dEPH=kQN@0#Wtf7;s0(Mw5@wSR<`HC&a@N{&NPmpT_o{ivA#+sEOlC~~taL}3rUhmsMdut!ZHhsOBibL`Ev3FmjWd0&k+?cWb4zh9iU zvDhr&AnOB8bLH|h%u1sg!Dtfu9ZU-L9RN0h3%?o>g%sG>s0g&8>hx|t_C^QPl1(6F z`@^JqUm<0`&&%!K*W}zqv(n^zO+rG#pC^)Vi^RrA_yy#4lueh9))VMa805=uALnC- zBhgHBWCCu3&%mjQfhdmb%VZC<9^0y~(6roE+u8fFRg`*{%`KAO-B}x>qnzG866~O( zM(a|W9#ewp13E*-Q{6X4%dNG%(Ifsw`{D}{a_^|slzRj+Y0CYOkf1<2TrH7#o60@K zp#s`q*jyQsm{xX0i_!+ey8S6~1-A`vg-VeF8GIB3o!AK8qEXaQ{#Lx62b^Vo>X<<9 zNnjh?#)}F-!rO^x?yUunvObW@7u+E&uQ*uKT+p0u)UMez)?u7=sl%9`=P&fq@{inb>d@p{08{-kXH(c!vd@xjrTsFS-U4NL3hJC^2;HSYo9J7Vt zqIWNI{r*8_B?CKcBzHMIy)VcH zR_R7Qn&7XGn3ZdICp7u^@7NBiwR~XUc(1a4!L5PjoU*%+E@kC`W_Q`*z>yv;rw5LY z0oZX***62No)$B>D{vsEY!>?gm(2(q?O!&HIy|%ecX<*fk_p58W^8WX4HpG>d(8C* zhj`fc&%=uq3{)#n$10{Q8Cf)HJH2JNv2P;uiuQ{{-8$)BHu2l$S+UW$2kHhlE-2~& zFP^WhRjJ0PWhO7Si?0~F^eUiv)`Oc7iJmF~u=QR>?Df%8*MEn&+-*g{hIP_RLsK1| z9nHawJNC6Uc&ac_{kX=nBU;p>sc6_XPvAXAku$N(FmE(?WTQKGV85d%w3@wsj6$~9 zB*MeDviC9_w=qnt{${ZZ4^{hv$nGG44>F~d%`Y0&isr+($r3kbcCl z;jO+o8&l$~$Gnjb)4Z*(`a|QNp>(uN*{@X{le=5!CjW?J$6*NPVY{PY;lMsep|hdoOpb#mr2%GY zQZ?v=L~40@?D0F_#cFYy-}y#S5Rth@*Xom4t&XS`LaoA{U?jPPNRqS=WP5|E)hf#K ztRR}EL9N0qcd92E1htC9YsG2i_tFZeRWr4U#G69YZVHijt(rYO!k%Ffg?zZ>Y)?q$ z!wrQTNiWTmXwQnUZU{Pt)+P5j#D$IXBUyyDzL z|8Mp{nTwcJLlNcP;N0vvzTgJVZOv5MSIsTGwl*fy%;%ee8-q1^dw}86^(M$qdDnFh zLY|gOs={U6gEj2x$Y$CtBdQw9hN>9=fc+m<6L}>qDZz-Wma3f`~hj-xckn&zD8p9J2`TKiU_sniN zzbaflh_X*nWlND?MDoiHk=H6t3fkYBVtPcX)3c>Qk>(zeXm5cAOaP)i4>t}o2RDr4 zj!3jeWMA)=3lG&8#$`tN>447C)mqaD@WSl$;KoREzhDh3T9N2L0X;a5t3Nx#%t_B? z@3X=Wu=xZw^silU6gp-sZHxX;L4Qah`h7I|g)tBg8$^s*1oE#E)rRwDKf4cupgw;# zlO6hdW_yEs3WI&KJ?p|OqIiPa%|0;Z(;=R}-3|g$dn7D_e#O1zMTq}HH6l36x)g=H z6I(Khsy2AjpQC`5n|WvXW~p7p=p`HE{@p2eWO!b9 zMG~J^@ne_vXU-Db#PB2Ewj-w-%=HIO-Ep9$3tKuOd4n~Dq3_9t#lk8z)G(s-mhl%L z&9~8xsu~5a@B;I@8-p7)UYPhOGhQ9DDoBJ{ZM0ClRjnX4HxgadxIAp(xGsj{I)&r! zPaen0L>$>wBMOfD6pq&`9LqBVmN8~&2?N!vs>bre@_f&fJG!oW7Y(4Lz$)rAI+`K` z6cI`kva2s!aVHRDqe4B>+y|{>MWmGj4xBW%KEu@B91w-IZ02hgh1lMsM^TSRVKtvg z`?E7;2fJI+S?URF7SQb!90rW;pptaOd!EX zcr1hc>TFWJwj`-|C6ayLi(C3}s!znERYof(~6`DoF-^N0GX z^GDW$K*dCF;CK(S@>Zr>UhEKguFHG6jB>a6yeDHpp3BEW*^rs$T_Ak(aTjYrQqCM{w;{cR7yYHReB_tRlq+2B~hqgKKbD#5IJ z1gd!c>JWiyorh%H_P(`wq^?Of z^S*ylw13>$X4TsS3di1?VOH%BYy$P&$j^B-HvT>2^|GOY`vE*_*x|6Rxi&p-eD|_` z%v3X2i1X&Xv*d$yu6(1Sxk1`Z;LFysgRSsGiH^f52kvdk?D`_h+iKIz>ZuHDdG);zux z#?6*s?q_h+M~HpUCtSfD78Y=8KF*2+lwhNql`RAptg+}-mbJ3cTT=gE1{Or6Z??M5UKRS0M)m%$V#gX&M7(@!QZy#S$1}k&R2algqKHvoy zWvp}4+o~GZU&PHUs%icRy^>wo1*3r=Qr62{%cp*=HKeQzm;XaD_9Nr?-17dC zBF|mqc}`iM@g-)}(XU!t*9of4H42HTX&^EPLdDnz!E58H0x1ck|KRe-sl z7m$egRWPMO!rPeC1tUs?dCg~Qys}khUWDK)k@VVN)GXCDBXGqIj+c!9NX7HHEo`&P$)iZmJB;a*!_2z9^@+MXp;2rci zKl0^$uq+?A(y~YTTI-A0CLh6i*3NWx(Le5U9-kT-^k;AIRWB3B%8}CjMY4EWU-bmO zN;mKCMNCT?0t1!_v4v%OkET)UhqJ>xv#Ib{UvN>rFSyj3xI$?St_-mvY_^x35tGm0 zm7d|LINZI=EJ9rNP~xU$Npw`W)>|K@xYqc)GD0i6au80^Fq9=!EzLb9xCC9V+!a?? zXf^$P!Dx{=;;Pl&P>BQeYA$RJ9#sc}{SHqP4h`E*>& z`XjzzJ#SBX(q}%4^WKPQ4~_c5AG)~G8=8{g;}Ds^Uno@GoPyg+=0uJN`VeIgkh3~LDT0XEf>jq+xkPpcGggwfb{vuyL zZS72TeF4}3LX{!PbdI~S8P=f9^$ERTv_(a z@1DY2ty!P9q#Ntr71&}@3*3`EqsP)rmGY*f$k^VpTx-pKd zexmZF$IUOSE<*(`AdYv*q^j7O^(|nCn7ZUD zcqLdbqE)IVB2`EzB3P&={d2zD>7SP#Qv9mm5l*4skA?nar%N{+AO+=1L330=(c}M? zF@D3?TBXOTkXDOhzpDR<7L%&~Oa;dS1>Pb9o(<*cu%)wRVk z|0|0%=NSwoewq61Nf2J&p#ieIjPP>pSX=^5LyniwKG2`ffM#c}r?3!7xqY z>zj9hML}q^f6Y)^Er|?o(T{b`T041g{C^^9NV3a>#oZ$!qBU z;}C0QS<&n-qy(huF9foZox}XW*NWLlJ@`g+s^E1{rhCUexAH9$m?UbXy+I)hKl5cw znjtdoYEEoLS}pd-nq^E@NkEwjW_g6E%`hwjf-lNTj~iKM{SqI)?k0;a8=DDxPGt_;-*9=3A6s62thziw5j0(sn< z`=z<^D#D_N-{cFvhWS92M1=>tY+p1Tp%5Z{_=ndo3%&Pf7P{(Io+F({)#Y4r>3NtWO z#fm=5`aP%Z^p}Hp_#B~%V`+M_lhJ=+bA(VW<@60G3_ni#$KB2D|Z$vp3d&-SR)45+OqyC5& zvmev36Q&Vs%^%3r6=klpC&sV(6)oaJ5!_*ZBOEoKb;>+HxI1qCMAo=FJnHG|2SKoZo<}*aGvL4MoAN#lc;2TrBgi8LIu<4YeUgVfI{p zQCs+(nLU$V&aYMNW4xoh)M}~aS4$xC(uVS4PvA&@^T8J>tB}?9AYM_?{=Oaiik;j1 zd2g7jeoX?3P1%qIYas4vTvtNv-cuoH?*a~KuEF&U$CURyfk;NnOeR)7ne?yB#`%MG zz7@>(aBY()+%66Cnm1Ktcyy}A?L0~|#$u`<@{>@Az!x+Ek1|=8-ngWn^h7jvdZX1B zo3mk%^y+(lLa$b)q@O|x9r}%g#PFCzPb~)XXI+?it#;S}j1zER#4<7B1ST(wl><%&Bq^wW(z1wEO8!)ax`0*AYl zeLHZtyID1zSY+2&Zc0Et2LMEsh@a&IWrWNx8ufQyaHGHa1{oy%*=em=!F`D%CGz zhm#pbKBL0evhLAKD82a$;-lvgQY88});tz}emE`Eb0WIJ@Nqqj=wlzt_{`@<;qvne zLtaM`s-YR_tyw=28=z45uLP71(R?{AQ5yjr-wH44-cD|Ku>mvRN|@CRufH=x$H zEVi5uP~GsK7umymt55V^z5U)l3251KFYmQxy~DFo`cOtKrB8t6oe0=nHn5+`%XQ78 zEeyKwlB)3v;zxy>2NSQ&rjrQe&-Tnhkg5I{bZxmQW?L8uPnF#AV_;xq24m+DTZHwN zG+TsuMJb4huuFNeL|7k0n7Ps*uM%TJNfEZ$Vr&fwNMI3TGYCfe48Yi|EZ!rnhFnoE z&DW$M_xnsjQ&dA98TI}{8phe$<`(&TnyqLAHzTANggzZ=ok{AZPSio;mgtApy3- z{CS{Nd6)D=(8}@UmJ0)Aj`0(0?V^-O1%ih~fJ&{zKrI5ZDtzauLc)>iL#s{EvgS0I zQvI1>mWCO`(26Rh|8VPfrmMe%co%;OaSolKOV{-D4Es+< zWpqnVPm>@ockkXkJHOWO=G8j6Wd& zD^&r=PYu9-LI75(0)Rm)6$W!p2*65J0OqF#aPtWPSg8uY;?w}{IUxWmbpZ3R3Lh@n zG_5=>3m3jDel;wE>M;+xp zW_{&_-k>j2%;AGs-}O7$%Oc`!in2EC@S2m=&K6>8_@_Uwap{0*q4bPezON}a{du1L zOS=`D&+eWYND=yVeeV#{5;+5Sy>#BPi@lMSG;id6bvi_jH*c>$SmQUR)N%}cniN=Ejj3a=&z!Qu zTb(J3aTHnW3wfR7!wkc+@+2qgnQ7iUd5v@oNgSr=^zs=_HIAW8=`(w3QGBwo!99L>^p`f2$+E0;dO3L0BvVtwUxp<$fV1kYnShn zf&=dz_f-#|VAQ!JmIP~wr!BFANI*evlR?a>BXRBCm$|g-dKm~>Zj-0pJjsyU@=bZ_ z!;=ieEmws+trKKyH&?yIuWn0av?azFFE=&Ltgn!)IqT=k?Tq#Layw0Vl(d|(UdES} zo~n_xxYjGh7K}ZI!oq^Go20aEOMCl6l`_4@Mq)zHo-K1cUxYKzxrnCPM0X>)ON)o- zbR7*?qBDqgwVXpV<^X@F;9lao5|`f6kEbC#xp+!z$>53jsBYd6T3C_HW!0pWNrUgD zjLSHD{w#sThm+~;%M&-DzK8vXRhr$<2<`qiI~B#4ILG#Kct zc*Zy@)T2j6x9*f_&^Nns_2SCnst4}tT*C;T!!?NO1)l4;%isSi<82_aK5*o+WpmMH z_$2WU4;;r4kq;8Xx622K_g6g0-REp*-LJGiZ)hrl27!~;fK-WKLrCw#4^A=kAg{BA zxk`s@C8@a9-Q3;gQ|tWn%hAymuo85Y_5@9H*l@uGPs{sN`0-EW0TUkci5EeGstqQhHS5r3v0#*EkP;A|Tyw4K-#568 z)%0xFX9RR?BKXa*`N1Jjt~i8BYPfs6>=`bjbrz?ZE$4cBNG(AQf%DZ`rV$AMJeEsTjnRX8aA zqJHj%s||QfV!@CvzF_HH&*Otk-)+sB|C+FMd7nDh@ax@%LwEJShLP9Qj@n-5>P;LY zx%=n5ZNGbd?A>hMJ+v(n9YRfbVRInz`@oTJFSEFP?e{ddH zZ%S$_PNq!x`jH0seHb`$qmAFo92_XKw;S$I_|4^(fZx?TmT)Qj+7>2gB_#ZpSOQ5m znLu-4*{SlKWS>CuS!HesWuTdsIq4nr5%B+6;K*Pb|KqDQ{@KeE{)4zB;NOqO0bCZE zHi|sPDibT$Zm_M5fg{V7ovnw1UjSYW2Wx;L!@)}KYB&HD)&Zrc&e zS3|FUf>1o^Z}<~_4d&+RQO8*sQdn@ibNsL-);OOYzSlF|M;C6|CQV|{(}{UUiCXo{a&tquTj7A)bI833;wft zYaZA2Tno8w<0|L6n`;Hve}R9x#$764<9~d@i zdaA9b$F}rl(RxX^Bmv~6Rq-CYPK?@U6{1%2|9sbeCj`X)&hvi`4{v6_``vr(wQp;$ zz1G@mzlO!Tdf`@nT`knN8`e1?)^H0WXe9a_Y3y7*8W~c$V{|c``iT<++82eHj|H;3?5#a=7Pm}JOYV#-7 zG|+S9-*!D$$gk@;HYoIv6GA%Sq}Ia?>yER(6YVcILRnis{;mAHXLw%V>E`(x&pSNt z^W+0jnHH}{V>96w`!r`g{#?C(cu!d#2L!2TYJKCGma7M_3S`4P`gdG6r(AD-Xw{F&!| zo((*kd3NwT%JUS@9-cgJ@V|UshFCPHgZVd1liZJfb@Re4prW5EVE^@oG1 zUBwc1JO2w8nmJJ?Jp`wlIA=f!wa#MhGrdYYc(V#~UIcAf86j0mB4nC&%=uyBs!+p} z(HF5`W4bG!1Pkh>#P)8C)D2@B{zjzd=-9rEU873#-20fG!+0+)D9o!Hq3?&wk`%*I zXNDbzujza|K68vD8Kiy^FOcD{v;4(jnA=RzqOv#1P6-3f3vEZRyS=uJO zrx0M3wB@3@Vd>5xdI?ffu^5wvzL+-5Sx@1Y!fX05wT4adn!cyNy9k(GAqh(F2u24o zL%6(rp`W>_yg`WavdqWEVihm`^mv(fqdeDDp$JmnBwNPbbpw+eeV9)I*NA_o&gZtw zxExgwrUr!=)Rd+Q-cL;0Dxhmfm28O>CxN+|aL*?X^7o?tutXKNTdR5L1O=THR88+q zOrO*1HJ@&;b<-n>;N(;=)ibk~7=$ko+<=sl<2O@zi3R6PX_X9-a^Oy4ik3_SN{k$-*Ta+f) zlKlck%k+J7FR%1KYWkOZy&Es##q{}@QZE39B7JOn{n~He^B}cZw}d|o^Or^?gY7Zn zo&4ZYZ)O`>c#Q*0ICf}!IiG1q9_b2rTkU{;Zqv3QPB|@u2pb*)=0`V`+YCkHS-!4aboYc*fBpF?^!%^e9f!kLq( zh?RLO9yRX+TeIT6)~r5){Zl&S$84Ukkb>TOSdxDL2oALg_JOn zzP60LZj)gS@_F8kGE0&!XmTRa_EMo-=1oG>>QGFiH=UPP|3o#9KIM;xS5?c=5p#?}0UgC*GgQpGYrf;*H~niN{%kO^E_%r>j@@4^#lk zX`@a7#&GdsrrOb(WJgCe-;OTJehrX4iJV?m5*r?jfq?l8e!Bkx zAQs*QsDv1YgJ(Vv4n6Y)KK7XS4r7PqW{;sDD$U;_J-*XKB{%nsDmX*SO9TN{Z zCAb|KU9^%rfk{0`YGg z$%R$lx9dxTIUF)7yC&k_L*)=iFay1xw=Ev)NZ2^?DcPIp8~n2Ro?uRCvgKPGlpJ=i zr9>82kjQ*;@0?_z*8~@q*LcI+s_M^7@tWuHl0Ng>Uh_lZO2`bwAp~+a8*+c*r4b7Z z38@=zA}X1gRceQA{);cMAk#ox=Ikl^bwFQ(#-+QzK{*5_ZSk7E5dO+)xq|nz_>BkO z6Y;=7)caML3!=bj;??*hu_u$Z)S>oaJP)@U=8Vz3#l=T#6YPTnx4s6judv zN@&GMh%)``W!3;hCU({0>8g3j%f1)WAml`(VmYBuo$zMu;5a><+EW+IxyqDc<4?%@ zP_AS6<2AntZAzaVEkqhysvT)qs%s;)OjtpUtk25ibbo2V&g{6EARWv?B=2x@fv@8eZ7?&ZwW% zF>8wUn2m?!=dl=!=T+NJy%y*7j2`qgl1KjHx&^_kDe2b##@_9kdGPq1b=t_nHr{?y zC=Uy>qz^0XnHq+Bq68!%QRFd%B@t5ih%mlq*g)h^N{Q5+9$YX5pEup}8`Ai#DPs5Phc+J804qv^^elYaTA$22zSvr}_vE&}lw4$Rd z;yrBvW=)1WEL}X)Z>-3xq)%4P7xU5htz!3fuHMq$D)YRRRqjJ6O+m8HxwnXWi}EJo zaT=38mKhq%Nyaj>3xhdx`6&+OT*%L;U`{nZkzh_OKPADO1^h6aFXv}G2C<`zc7`XS zWqE6K2Ls?mS`!z@xbB_@VKvhmm&GnMk9x=C&F1{-`8+rBT*C8Zo+Uir=FxsBqJym$ z>dXp_gW08!yZogm@^@%`Bz^tVg8C7`>?qa)cjnjQZsOjT5BF9qXUf~I zRPSS!6j>O8>y3#&$nzY}4tEIePbTbtX+0zC%EeCZI^`CVyRN=D z9B1gvjz?jDyO7EJbnk^M5H-a`O;^+lF6tQ<^|GRNx~N~ds6QwQp9so$jf-kf)URCB zMi;eBQ9pE1W6)Yr;y8Y~n_Se>-{7Zvx{LaE7xfcG$zLmIH8|)QK|jJpwYaDsC@RlIo$fH5B}`w3*pYFL zi<+ybCtcKqE^5A_xjQ{e)SwK{;*ztu=_Hi|5COg03E%p1syGF`wRJe8mBegK7e|6wqT&grzT7xUA-XaCn{2{JC{63wXm3O{sg z`LF|r=I{2W|BXw(z@@*IpPubtwweHC|42UrGuUU->|L$v=f;;PJkTg z5|3DSz9dvD88z#~F7eSMAsURyiq0h?)?KIXU*@%Am@#;wO>>YK#?}pEVIf?4uL(QG z%!%?cjmPJy=0O%TXIaOb!fyk&Dl?}zbMaOF^x0)gPg2$)76p|o3lD#+{E+U;%o#75 zS$uDCq%x26!8ucnqxuOTw9;K*Qm1zH`Gc@f{mJRq7keu=@}8Xjd2i)k2>6ZHN95ka zTls4xlh4Y=a~YOpRvZPzGkgy^nV+p;}rE*k}J6c$GRJya!z546^=U48ZzwgRYzwOl_Jq5u};k*g%U;dB@1rsI|hHpju4jne3c*2C?BPNXGWmNcK^n}AEOzIWFPvT&1>dX; zo^5=ANqr{w0K^(UJm;bdFXRlJ7k@T=dR~2P^WOUE;H+Zg6z54=Rg{^?jUYPLF?I9y)~J8R8ZcVig>|C?V8*7ORopF^CoiKap75HP`+0lY%Is?iUq^MnYB59VK1C9X z@tU6?innsUkMG{XOFWo4iX%^v5*mNZk{Ns3^6SduNCumK>r$4IGU7G=Y;eBsE1%K? zgVOZe0E8o!e3}pSO95yZ>#rg-9uOt zCl6#fVIa$qE>X!qq7k`7b)^S1u(v{Pth#@|pF|q}SZU0U`q``gM^L-IFeqG)L2BIy zb}yWHhGj)HSz14w*TTAj^xq4^?#98|y)PfVWEdKe2f8>QoH?f3C#@Sn9Lw(3pv#RX z&R@dF8ykP?{&P#8n3`kbD;ZUZpttG(uPDDnL#?Cbx6+aK0<1v-jRm}%(ytRZa|y*B z;1B)O?#t0Y6^;h_$NIm7FK`}G;8k-@24)h=?(sXP19adB<)?XZP6m=K@dorqcH-(7 zODex8qX{{`D1C5w10DvtU*(OY)0f}G`Ze~sUvLb}yrOW9EU$6-ss?ac!iRQ!D3VZV zqq1Mz3X;nt!vR-qzn5j=9GcQn?zhZa!{V=JEf0i0zLTemr2XRlltbgoGU}_h9 z8IzA=mCJ0E7_&wv!MY$qTu?=AjAz!E4qk z3&u`D^fz9rg4ARN1ZLxzbC3?9(z}b;AVs6j=3XBR&vSb~Tr9wgumjXqA~tEe*Ysj8 z@RR}^Z*O{-KvgggsnC`nUY_3be&(ELIyrwvm`zVBtF3KwL`WfBGWx(d*kfpBURJu@1l{d-TwnX zK;SQ;m+e7(t)Cg{XU;)wwlosNr(|YN?mmT9a6NK0$wEB%URpObNUaU#-4(>w`I*D~ z%tesv*^v;RIO6Q;*fbO({kQpsPX7rHBj0($D`V@u_Bl|VKC3SoDzu)ce&&)Onj^?m+ zm83YRZ)Z=DBxkB5Ik1#x)Stn?#_`YWDUDks%ITPG0+F7R6s@RYMM<{P^T}LPWFS?c zqR?R#*ZpfO$P48=7h$QF{h}Ev1UEI_^q&P1q7zA}aQ^Rmilz<6%h&+a{ z33vz}5ytln8;BfAF_F5@$Ae-H(;q^@Q_g|=dG$rzjwW4 zJhLb7m+?G5X2~dvFd5J6$uMXLc~12kKc80#qjv%M4%dtj zL#@!#VKx_gW@zf4#(a%sh6VB7SY}RP5PzGW;vn9~&!`~&4}KVJdHj?F@j`x>-?+b< zGhRm|HEoS3zV|aop?O+T?}^zHS(f-(bQryIr5M4#{u)NGapOXwSgq%Cp!&+y6K;cF zJ&Kr{GXk*puPfgl7_`GK#X!R#yw9( zL<}m=`mk+J@kpH14dEAe2L&TLaqiCnct{pj8HI_K!ZC@8^^4bYRc(}9hEkKbGeYyc z=EGGiGBwO&`?;sAK(Fcda3b<9oL@57kZd`>pQSi2@BUWun8rQlkI&%)a4+2{y}5Vy z7_P(d{=DA%^Oh@*-+pDfYy6dMdxmh!DsfjHzv;??&0!eF+JnBNy9&a$U9T!l`uY4T zM{dD`AU6UOCnnK8t(d`gEdDN4xPpSjz=7@upnIWStu4N>v2wlq&o=x70MRX_V|3bWh4T>KVl6x8| z@*%Aq=+!FHNC^Ix!^q~l^=e+LNay=4U;V%tJnV1(P9M{VifvxnCl$`DMka9D66|W+ z)1}G;569YGEkHj3^1Bq|2ifJG@hvm9@jY+kyLu~ad!@jI3ff*Ns%jY(wgmTC?4czF z{X}AW+w<#&iEHZb8Q%7M1W)Z6Xd`#QWe*wocx>duy8n(rhX+P)#e7%gKd|V#D#Q2K zJg7{`E6|U48#M6#O|RAf4VH}Ra}y6-_A3ND+5D?!aElK zm9{=zX{@dP#UL`-t>7}=as|`!WAthT4SI)H@Qr5J$s?hLK(m;dY6`p>(%p<*;?>o##y*78 zqye-T4{m*6eoRw-@9yEukD$#i&C)ecZT2UYA!VDRtA*Tlj;VaR3YK z2-h!e1B;c)X4U?d;D^i75L~Fysf)ilFgfck$&J8)Il08o(LX$2?V@)WQ+v=;Zf01y z+4*sFYu8}9)v@^3Yx8wWqz-pPw9;U)RmUL?aEZn)-SF9;=I3qVne#Kw-~U&B-XD0* z{W&(Hw_}Ta2j}N_uKb1LLBxNUCv`TiSw<7>i2KJJcY^+Y-UZsgniC;hBg=_UD~*pT z>iiSJJ#6zDvL%B*MTp1gt}L78vY(aFAZX?LeNohCP9Hv;(_Z!c%xE zdFJvg;92C2X%@i#vFA}1`$cfPfzY9FyiD;Mx+@Kp_!>$-h4L@QDN!bVE3nq`wDRo4 zSR#HaJK}g3WTtL}+d6B%92!R~8A}}>(AKPf><9sE@9v>Gjmz_FYiwVOD!KAx>}x-; zuiN+$6%4;z>5j-pYpwAGBGPnSDID9Ke{ zE2;5lk|g+S^?Wr}SDM{Vhacqbce%JEx9VO_D=&!jpJ0x~E! z=prXbwRT?sguU_NA$23Frl&@E%@MH3Wy_@};W|nLE0^aO@`Pyfsk830*w2D!m0_7; zzaC$g1ltfv_AWR&O5K^vTyZBR4!vDwGl&G1ii99j0x8S^ ziBuz=1NkB#*=z0er>E}3jXZxcsh!$tSGuislC5@O_Vcf@)sA-sJJrV;80=Ha>dw<9 z`*Yf4bDzx_^)sumj;y>_yHGkm*7%TFO?<}1YIB{7MQ8D;kEV^_|J-72t6kGF)9fhD zL)hDJLAcLuSoC}CvpFVfrNq(?7504Ef<4FG`zcDWm`~lS9kvA{7R#CJFlMMw&=$Kw zqwymK6eq}lVkkM;UN^pHyX#@p!)z$xU8)%0@-b@D20Q+AxWSe-|3&Sv6=OT>_|tBO zUFA0}Uz=Cyw%E(pS?E)KdihSzCb1v@yjJ2iZ8@~LKsCBpUq&nvjMDGgZyCLojPeMBE1mc zjsRY-Vh%y1FID`8?lUR=0RiaY88?I$GnR^E3ekY&KdRkzgGjmy_4lh^z* zFMeaYUXqj8D`PD0&dknRi;OFK!5JuE1Y^~+HZC_}nEs|4p)qHoo(PuWuSurD#?H~K{YOCsrDtR1m`p1qMsPf4mxxdQC@;1GGP2FfE{ac&^Hr=i| zxXzNF(bV+o=hlySK#PUB;Z6lt`>E#Fb5T#Km4kCCvsE2tI6cBvg6<6#z0u75+52uD z5=*~W=vCnel6wlUMaKZLGKKE+#=|KB^*|IIkXDl^tu%otX*GE%RPEInJi&|KJ~ z%3uy6_U&kOqWD$?R+-slZjU#=h!yqKtIUq_nwLM66*d0w@rIeV7)joI28kF81S!~J zNe$EA>Wo^6h-t=g)#*?yW9WhI<+Q*91=Lt=@>JEJjywIQVaNUHyEadLKuU(>E8QdM ziXGX@te505uHL%vr-So;$ndmIzm~l2xW&a}KVZPZBROkb_!v?FXSQ&-7&!f76U0=R zSGJK{1kxLG&UocM?B@cV8(crgl{1F)F;@OJsaz0WgPysLgTmXh!-W;UUmu)nK`z%* z-vK-8Zj;xr#}r?kXA8F%Z-a_enqQt#;tP>&O4dtfAmYFUn3$C^!M@|6enuqT6R(}5 zB7ZQ;75Uz6gAjdPFGd$AG8T07(8=4gk6Wx*O@y?^!so0-eGAirW?7m9b5NS<8gNcJ zQ0<@Qpp65Xn0CjD%7jbosl0=&Rc{3?Yp9Myd~g1U@*6jb{gubkOSyIZ%GKY!3q3B+ z7M`E++{N=}o(FiuMeDqf=@1)VGdMxfxN#xh+*IBm$Q)A8h7CK|xV$cNzb5WD;WBhY z0->PSbiScshh0{aFnjGx8V6x?C0A59K_SbaZ|w3_YR$*0P+wN68E^QjwDrr*7tg($ ztm_*1Bd+^aejgNf>J~ClVQ*HSk<8>dEt6048#_eD{EB@`W?1K{d6Y@bBS<=0A!KCX z+@N3DXVaTehV0srC-nFvZskGKFy1X>(wKy$&o2niQ z+O)-ix^&5I9WwEr=oB7R>A$CP_RA+jb)_a)7`w;RU8E3O1F3ijM(MEK&YWVnQqf7; z64PepMm)a~(r@xyw0k&6{c4cg`0V!gYxgcERtpn0hWqzCzvsnrib1LsC0oeOqro{M z?H^ledFp5Ok3EslLHJM6X7l|J zxo*zo`U3$+CrZc|Lmo0!-VK*gV;$p~!mk<8jN_|WERIn?$G6U6Bqp%>FD3kWg%MM8 zma8=uw5s8!#W~nOM{t+~>h9h6V<0_1w>p2J>Y^z&jsBJRvswzjPS*jhJ*tBih21o%9&xuDiQcNCNS~x77Nw@o z?yH}{O_iD{qkgVwQ zR-DHd30ziY$pJ=PhD;*(T=t@SG3^%7B1-;O>yl<|ihY6V%K)Qj#96jB&|?$Ak}u7b zOr;iZUy#@Qh9DR0<02<`A2m$HKNjC7-|D_Kq0SL83~7QyKi0@JwjJ(hpQ4dIMW*8` z9%M5iloz^X60cfIi*e#)J!40N_>`zn&#YKvNx|ort3+~T_HW$}Em8N2wrB#t=9h_{ zG~R6VL=#Z-BvuQ-J>_8}3y93=_bG|#7nYYU{f!~IpC4eLlri{0)w@wUfPd2EV!vFM z|Cx|=EnU8Ff0qM=b(xW1J$~#Tb3HzZG<6YHlvj6aGT4eMN*%kK>>~d=cSkn7HDbETGDCt^$ zW5L^6M($J;lX#JNWX8hXYl1Q6YOMK@EJKc?&-7}Wy$bATp;&$$K&&MD?52np&`uxgy$$;4y#ckPkN z|6}sA=aa(0_39Ot`^w^EK^^TkiN^Ap1d%hswxZleFW8ErBdoTfaG4%*&gEKAuCvVX z$SDuY9l5KQqc4@YBZdC-c`Pg6AT#t;eaD8T)oU_Rh0O!of~Z?aviiK?92pexyHsVl9>Z-S5>UI9&$pfxU8{#kr1|o=}g-cy51>{P0Tk z$bAOJI)#h&NZ?O$z+Bhhdc3ZDna2CFuv6Tm9o#S0A1>Ojm{(?fV;$qIc)|Jv{?$4p zT&XMlia6x_m)7__@YoQp?8FD)mPHS9$6K^;i`vK*BFnEf2W#y8Cbvmo@!oioE#9J$ zws>>FgM;?e{hGc#Tr%hALC;T(6Yp8?2rc>uSpFgYJ@eeZBG{B`xGWcWCu%7l_nr~V zB)K#%HbEC2eolS~I=@d@fdspClhIgB@Y7vge#Y-Zl@Z;bGo#$txiQ-PGm^n|C^CD? z?U?9gAAEm^ZPEtOc+Do{uxL~EAHhk#R+-Y##P`J7i}6VY`F^k^F{v|gZ)je8lJxld zx^+8j+lL6D+VQ`6reDC&IHy?Y+}F1E_=Jm2ecG8AsA8gGm)G=p22---i}~b;d`u3? z*|Y^Q8!qNNx+k9HV(B|vw1ge zvF_^3wtSDK6E)jpd8%o<7iha#eLZDrxRUHuN`L_?B!!g#SUsa8UlUMmiPKtn{ghzWSo%r1d>V%60yv_1Ry4iILuN z&J-m{P3k}-ko_@HtCKqI%t_=&w2h7am&RIqvITJtHw^L|bh}`-ofHpcyX^0)?+)%% z>rakTUBtj*sM`%r>OxJ0G914a`;$fv7>S=`UHG2g)=ximxPF3m%Q!OoC3pcJ@BV5C z*2`i)ID!gj_;r5~gj2y`++FGzZmH$a25zj>?Rec^{n>tNP`m$7{!C-VJgDyW8y<&a zaFG8L4o;0e;m2kU@3&k)hLPNvT8!tw?4R$XQ(B6q@sY!szRWOkS7INO|0OIK(DodZ z{~DJ+a)|tZ9j@q(81sx~7p5&mF9U`PL;v6S)*rIGVJdGhmis5%AbibV%zQt%ATp>! zChhX~z3Lw@ar~B&^L=#LSVkH#C`|@2=KA^xOdAF`!MAKxw{ z&;EwwG9eyq-dqt@Wv79H^TEgHpqDNiq+1olN^Y>$SwAEVWiW%fJ1CD|_{z724e*r< z;L#BFaBWPoyo%#~@ytZr>pZAUuI6|u*yBat@+)?DE5{9k-nPt(WFLaG<>cH&MEYE? zAp)vIucLUeM8C8|+CX|uyNQ&m|7u~O3%IY2dhM+s1TWzuz#XTd{sqghHG4k4j`{M) z#`Qa6lQe}{Nb+*jmwDj^4T7g$rFgj0QozbhOT;R9UwJQ8;`vCbdw-F0er4)|A3z}yfE`ouc9;sN-ejFj;Sr5Mrmg}N0e9cG`YM_JgJ-Z zT7`E}Az8T4?=dW&t9zBDE9R9vF4*3ftGM-4_ws?lHa^vi{!0jUCm$~_p9)$hv=T-B zr}TBG-R)6{g2l)x%Za9vqj)1kH`m{%hibsSu)r)eoAh~D2)~HTpUi4WaPAwKLCC?E& zW7jPTKI$-2w?v9wCQ7NNP&)XQJjJ8CQzN6-DzVk!uS!r!f@k^o9FHIX4=IAa%q7U$ z9s%HA2YYrF|WO|->Z4dt9jOI-`o#P z>Gv-Ct9RMcTtep6w0rX(_2xh0UG|W7*+y^vHgEnW4SyOsZ2u}}UN<1$Va(R_Cb$sR zi*E4RH~h}4-eARbp5s+>A^H}+4^Mcr+r4O;*WUI!R?K{_y6xa3_$lu?X~Zw>bt#@pGoc=|{sJArEOtknX8=~B~G*c_cP%fJD)7>EYTe#@W%dQe#+}v}W7O*eBqlIKE zch81?LNua#p{>%8h?>h1TGXBK>!`Ri8d^mp9#}v^$@2cxFobdRN4K^$Zq!m2voms+ zY=Egq@4@eT7P~8z+X+S6EDM#BJC~l!FX9WxcfvcVy@wkg+GFXLl{hi7U5Q&s%yX&k z7adbrE`;xX+adfLe%V3F7DV{}lvk%kyPTMC^=G|jzmW=HM|W`^D1T|K6F)JJ^RMCJ zw|Dce8ESh~gknfj&6zcy=m?e(w8RHp6d>a^P-An;5Nzd=nx4B;6;CWJ$5KnX4xF3S zd)Z&XRwXh+*1}*A=gLcEMuhLF00%^!v31Nvy!7l`T4AUR6)H<7fI$MUOElF#L851y z@_imxtXPuPpYs1Fe<>1u5!%0m?_v}@EH=0Kb(dz7R(b^1+YU(9ron>ho2w}qGCN7_ zHB~_bMZMP{l1{dq&22|WZ4xclMM8NAGlOn(18{3kX#(3q=g zQLElSAt}RLyZ0H%8s_0xkJT<8;(ceihvnl^#$J>xQjrb_I4xCcj8>)F*_e~bgjPG&9ux1?|{ ztqO&e={XA2ta97|5VQ{ZV$iIviG4eJj^JaeqRl}|OlnPX*JoJgu)Y}H4sp&(7J55-ycD>N5N-*!aJWLN=%nyAKXb8k@qXsqJ_b`VGmIARz&coj zhL3ZK>0PMPm~+`jbVQRC&v+}IF*>S~3GDf%Ne)xVw2qkm)5f{&7c{8Y4&S{y8E}`! zu7gH1m>n>na-(@*+-*4IxUA~ zoAo&KDUlhYhVr5t`7y$r=o5Ly6=%AwEG4_VrACx>(iSOogp#20|7)b!njZ?OvzSAh zL8{F#ioqnXws|u}F@;bL;Xo5%=}m2K2fv7~p>6I#qR?QeKR!SYu> zMYg@@kLe+z{@#Pgn-y{p#Ub`hCEP0(_Kf_dLTvn_Z_v}HzMHo2D-TQoOTe^}?9v|n zc?oJ%SH!Nar$EtcY2HN}%iX~e&*>jL!w1ESFMv%trIoI#X0KDd9d2qd*V5K*A+V%? zkz$06JAM-_@jiqjT>Zdl9@e9ll1&0)=MQ87#VGU(-t={4YHy{I+ z=`-`{&%mnYd+j6PIj~n*E(RdWOhyT>d#k%IhsgU}%$FHP``3-I?AiZRUi>~~78OhB zQU=x>ztSFcC(vW}NdBQqd)M+Sdns?ClLiyGxx9f$)8IU=DMwF?orR9o^si{kQZ3~Z zHUI1q;$tcN6fwnF9M!Jo*1ihw;uqaGU^ea1YIYz4)I~eJNR!pR0!#G}{a3S%-0!Lp zKcFM!l_%Ox^kvYZ`D_pScxM5c%*}6L)lhddx6QWcw%LDat2^8)+3d}2W2v0fN%Urq z8)%uxkf5hDm!I@be$Gp;Cz~9vO~RhVHnr7{X*n9ov8?Kz5qn3s& z{HsgHCt5~LL}@%w$r(m@#(agQ9HXj2e8KlJHn8&_7tTkMt!s%5!Q+iERJRkeVKM&N zqOCpbC>)J}dl%8z2sNu4?nSxkxVDoq8>ObTkFe_lZB1uk4hh%=_O#Wj-iq1GTnIHR z9H#+(1izcZvhV;bwH`yi7lLI(hW(it<^Cl0PH@Hv_E5#CiVwUMryIMBB|U>9nZbt$ zz2JIYoUcZMIA4ts$-OmfNZ>{U0VaO3#rcDvYD=nHTinaEzVv@0wL#i0J2i_>N|MgJ!PW=j|(^~j@tp}NmzWhurT$WoXE<#YKC)! z*bd2wEM15%Hw6%<<6>+sWTby?nVsrhrI8|e{M}zMw;@oh_(MHILw+*^h+(EI1GH*ky)MZs#UW2d(E3RK+)AOb zY&@u8;pbRp*(ex}t5=gv_Ef@*Nk1~IMSlO>2&QAl9`Xa~$h-zTd=ymXH7LOXZC&15 zA{w)Ck+VUt-Wu3Fm_GQzaA8QKm-&djvdPR%rmIQ@d!*jd!i%uEQhrl~RPMTu#*JD{ zVzu)(NIoV?oC+32M1R&=cv-2rFSPDmh&qu?Z-1h>-D|#t7f!cg7-q3^i0$y2{pfi;F(3XqgzMIM$fNh4r+p^CM=a6csTC^zB2{VwlWjFFk zojq6cf&(Co^-oj0RZHqkKd6kxr1>Ru9Y!4Zrv7Rv%WbLx4S7|$&&6nagws-%;SR{V zHdUHoUemvjiR}YJZ-KJ&O%=-32a}3QJWrDpEkFl8Dwq)R& z#XEIV4oojYnYkoS1lxkjh-La(m`R0^DTQ4s4nVz`&_HZ4RREW3IPmUwlzJd5I6! z7*1}$$^^01-rcB`Hc|s$bWyX(EI9Rj|3sq&vp)owqqe`%>ngn#FyGNbidX1a=RV#O z&T;HL#P~%SQZ~O+L9ga_gx=B-rzUeS&Ghy)y`gD?rnmCMbh9=yvXZcyjZC*P0So6k zgpVn<%v6pGP}<#{BLJGeHg!M20VX$f`(D^Tb@Pa25k5_Gwp75Qs;WGRm$() z_4kV<>dfvH4Kquz`omT0?*qxPMzzC5;BPO-)m5I~Qb_T4l^45khh};&RZ6y$FBK=$ zMcC~8V1b&-cj`;-EskpmFkWexzNq<5-Fd^zXw)k{BBEZA<@bppfO{Loa2 zsAP7pPywh&HdExNilU9G+#EV>?3Khy&T~|!1`5btN>aUKRa($#w0NG%67@_HNZ@;_ zD%ghIUMWZV%ec=sFEay+(8l7z-B{5fU44SOiMRv%iYkR=J81IVh$~s^-mG_0fbljCDE6d&t zI6sYTGau0I*&u=ElNxkUXp*#0vKE|fYZB=n%4}>Z(C^4Yvn>(n4XjVK<<*1yMJIw; z@c6XGedOQ1FgF$KusIJk(J!s!+L=o&J0L$vxs*3- zFM8ckQu)14vLnNRs$twyV-!^S`9<`H)a|z5Q;WH|k*^WbgIxe4O@wVOMst!ck^a>l zlvoxmE=>zHVD?uYPJ!B>LZ=e)VWdhdRwd5LRYKGW@C$`Mbx{?dN7zdQX2@}a=O#=&h2RKa=FnoZ z12#e`5j-TMJ{F@7dmKjHCcjm?I>2a0sb}SWfW0BWe+q4F2<<=iLvzE}rpE%93;6@D zWCb;m9<6ufYIX&YxJ2BaApU^694A4pj>mpllqA|*XmMr)S?GG;Zgh6 zWGH#HPk7Zk*;+mARimH#iB;Q+ZuM#&64n9;zGti;i*{pzRYrBm(1AA6qG~pJwa*E7 z&68gBGot3LhQ?1^0a3RSdo>%p>a6Iu`}*lK4K>%!=omNgY~y*Iy=@;wHd$+WweM0! zC)iNpKfG#ov|;nqvI4oxBtYX=L%T@J7+udc5jc<1cWq$B4+_~DVp(!(xwQo`1I&W@ zj6EcP*+M@=tg^E0tm!~}BE{!T4o}ldwmXpTfZ99uwU$)1Z+JCtX~bR-aj?4$Q@R^g zi{2OD*U&0jv-I+f;abzdH}rnBtzPXuuSOx3yBg4sVpH1@b>+|uRJ5A(wQs7~XxG{| zg`!u3G@+UtV+wr8Z9@JvnmgpR7$UrzMwj_5{&PiuAexIXs&0k%SKu;DVIDoWbXE;gw9SlsGMDfGLqhymJ~{Sj_-jlrY)bF2lU zSNsMsjwd1DxD^7dzwQ%b#RVMWqcPe=grM~DD^Y-0C^4mCpU4_AEb1vDWHTqePu(bH zQ0*e#5AJk;PXy25A)aqO5wfM-7ibH|m%lA^HF%sasI_!(#WAdR4BCx8^pH85YkW<; z5b~?GA8p{h3In#Vk#qY|^+^2~=h3opqcX9}703EV8xY2^ic=Mr*~M3JiW-EeU_mL& zDx1wZIA1tbt+beoveT508^^9Dz|=9F?#>$6RgQf8#MrTQWk4}@6>DB9*fRE!-@~eg z7Y)s^R?r?*?COr}3mlnn?5eLO%eWI$8Yy_RQOAStP65=_AO(LD*VwApmQlFppE^zZ zmAQ@wV=gT7E1GO5dg+xcqTz;hFu&MfdFOJ%f;DMdw&s0xm*Z57U&Q&G_OFa+8?dzL zt>nmlhIF}UVY0!)v_^|lEF!?w98tu@?29VdIof4GtI0)lY3zPR5Lunrz_E^O9yOM$ z*{61kD`YDuuJG5dh%2Nz9QT6VW4*VJ>3`%eU8+gI)Pj8^e0d zysv)V@niB+fbbb9JRY|m0H;LDynkTwFNd|smUGJxeI{EW-Uz@ zmJ*WCK2lp+j5!7*X{#|LHOWncSrn_bX)PbGm9JzwRuahnXZISTQ_aey*P-@Svl}HQ zdYVL+%0N6>Rf-%wEM(e1A(({?6avUmthHL33t4F4!@Qacx8=D)9MoJHVUYuc^qv(Y z3+8eGPeG*$!Nd541=T8qP;G(#e^SVBm9fY{TsTn5G8MW&1yI~Ut(Cx-=l@5Gu}-(4 z_`~T;b1icsRq?2ozLCyKAvg~n)*a|64PZ=4pD#*H+5*=-F5(A;b$;jAl8&Z*d<83FBfX1;|X03QsvAc;i zqR*;hPCtjMI4vyVAhbW8W`%OwA^Xv-DO`GrbR$}WWh1Z#5NHzgaDyib5CO}WrbMS4C-G(_KrR!Kx^ zUnO|~P~w!0{uz?*FFrjM#3G1Eg*-?+l4#4SiJ|Zy1-vrt_!niPzjsvd6=0cHr)2O7 z2sFQ5HwA)@?b;xD1F9&$1@kQ+B?=zX1dbH8qg(D}=H}P$iR0IV)s^EhsJ78vGuUYh z26(pMIakPvTp>E`UoelEXDb?Q=$cq9B-y6#t(YRtgavPb^!ki>$GL7&BUwknpwdNs zs`~o zHC98@u{}D{%v5#KzJ&MN$Nhp`dUd0ql5|auB?&MY$eK9Nujn=rpolMgWxM!5>E9Tq zTqLwDJL@^l*<>kg)i5hfgV%VqWW{@A+=-`$bk~rWcMIk;&`G(Yh$xG>DETT(yhc92 zJih>;-A$$#5=mbsO<)0TryVzjXJcf7mrEY@k7Pxcmp;kFK9`p-aP4=>>xtP-acUUUes73OWhqE)LVMWVjX6bfxN+&Pgsg0}VM5^Eh zoTKzD{;k#)f4{#)XisYTjB@YB2S}8_^aj=l7rav|&rW~)od|AOzC*m`FWSi0>!#vD zDT23|2$6Yb2g!FLXJ@M3F&v7r%ekn~558Wk!8})47HB`w6B`J+<)R|%t^5&m*G98i z`d^h#-I^x$V4zVxLcvaX*gjkkV+yfQYn2)*SutesE>qI4kEANbmoNT-UvNJB z&L%qgG42DT5e7Q5(~zkvkE~7wL(c{e+F}!NoL_$3Pk%iRK1<#dl6a3YLW=nn^DvP~I=TPt7ppEhrwHV!U!eH>FA^5t3!5{x|;6ITAzpW#O{pEwfw_h}H+XYgTa4!F!(Qo;P1vQ%K`m*#m9lK&Vly;j~wg2O~)&h z@qWv!LZI4a#SAyB-Lkzf%&JK8;F8{_0S{S`H#FuneD7!ufmqIf*V&Lv%pkbjX2143K^N(_qo3O#FIrlc}wien~Jr$ty1 zTZG6jSJGNo{1yhu7H(CT0@vsl!?5(1Oc@EShdGpuKX-&8fduvYodSqDXoeA9g=QJ(k*06 zUPYZ2=H!(*ZZnB?*<5O+yqRdZTN5qU=4XHUG63N!G$4`{`#4=IO#Os6^FuA=uzbrc z#m%mgQf0ct_LYr|6Uk>Uwp2*t6thzZ%ig969L)}?Lj1KDpwB9Rd^6Nne*-}$&6Nxm zm!^UnSY5A+3cxA=sLwe4Ux9@b0(7BA<@yBKn}ZFi7|sU0fo`Q8z(6f3Gi80)tq&S` zVo7=qlge6Aw2;n6&5U3SmI(f8=bhMl0-;Qky#QgQl(A>SN7 zgr#jFFI)CpftQBae{pbj%j~iA4+Nm2q=h!Qp%z;@r=ED4{ zxo?svac0I3g?zIHi?(qZ4LF5{-rM+d{5y#kvat|$noLw-h?>StDDnE_UMO~31Ib%) zJH)m7Zh{HS(h$%vBP3K#y)Yo4L9gAa8Aa@A#A-xTIv^UG6NI_ugihIMPWZvKJYQj# zrx_@LeQ_#yJF)8xY#t@?rCpdSaKJX)b>zXQ0-^=9Cw#|FIA@Y}fIXT7EX>=1;j8v! zP-TyJ$cR6-Z?uV)_${JJFr)oDJ|VBwkb`i0d7cZP zUeu&*88wX03>dv+S{kS|=M+q^+_J{K@EucdzMFE5V@Z|-0fW91$$HhYAdJkktQrX2 zIS^7`0+npck=D7>|NR@*AV1;vcyxf-f&{8c9COEF0Vs4iCW%txWL5M zB!l}Kh~T^%Mi<2li&c!&hAWXLoZDx#JasEjvdfRni34gF;$Lh1*h_j#e51b30z15>6_%T-L�!4*`xQ10UwC4Us(p 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�VnY3HRJ|4@~S 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

u_us0&TNlnd@sxl+9 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%0DtSJ(})2nc+t>*>0iU*<+O0Mpno1 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 H%&i33| z(Z<=HQ_s15xaZ7?+|Kr#Ih5Pko;z2xakl4NV{$v&bFM?Vjj@M zcOKVX+B|Hq&NJ5zML*YF+B|I1k1w%CTwuS~t@+L(kilNyWv|RR*H)TxG2Y5%9qaPI z(b-5X_*7~ieLQxhS%~hkENDtWodUOKdPVfKRzxC z?m6Omx*YRhlF|R*Vr~DAFY*EASX`HBVj)-b@Q>@c*#7t;Phk4Zk@LvOa=feuh93O$ z-SO0C4{*9-T^|BXdt&IqCA#ikuKbq4ug?uPPKd=lDKcFjKbnSb$PokY6_6udtVF!U zh8V}Lj~_ef6JL?T4{~?j!&hU)b>vm?f38R6NbawwK6QOur~c!8&*l?qrMwa; zzOF}P=V`--?*p&!gWP%XcS{%_-+AU4yk01F`y+>XK(6cKM{O^QBcG6+|41GC1)s3G z&l<#bhC>8x9=Ucf2N;ihVi#;9zJrlRFpgl@CvN)I*)dIcs_(KM{IIL zA6vx5cYcqYW7o%z+V1``~tHrv&qQ89NP+E+&%f?^EoW<4yu^*){ z<710>AAujGe(SO82bnCFVqf#8)MeE(CTy{;Snt-JV@7Q)=Qn&%f6h~|i^Co~*oVw4 z>W+A~>sT49wRVA6)I(W&oJY03mUEoC5evJ^x}i@jY!n&z#~-jfW}Mzdtz=*ES$$<& z{gU5)RUfT>s*g1u)Z?g4zB7(Te1xO3wMm|d49-?=Cuhs?X`qH?7&N` z-w)rYei;XSj*A@a@(^cCxWi@hSnzOcf4vrh!GrqQx2W^`@Wt`64SacY8Ur6Q{-H2j z?>Qfi55^E;B?iaCpV!4Wj|y9S81F>&nQMm}?s@H)&33}9pXL2|^sVyQFW2zVSVQz_ zaj&rpgJU?F$_Z5Sf98g~(ntK%vT?(ev7KpsY*stj7yIDR`jBsZ*sDIwEwalT zg=5r*`MkgS$m3}v*T;6XoyW-aAx;dm*59^1E%xrL*# z+(wMcy$9C^^4ayPwINPRn_43n>uhU44s#9Imumo@$px5oA#d{R8nInv&3D0T4d5eo z?ym+MFWbm9V2xxv*MPXbJyjlb!U5uZt>I_K9^J?)s?hZ(4mqZ0Q$utUY|j zzJ8VG7}YQ2x_-A`alL*7F(8W_#_jqT>s6~ybQKJlsI3x59=Gdfv-iYC)lY~meLzlb zrhK?G+{TA&Q@`xfuYEi5Jgt5(ZS3qjvJH7QF6<~nP5jL#4mn3Z=XR{Fcz%H#df@jq zbr<(%d*WXd`0Hal^tsml%43Ip2Xiv`oZS&NFOKiv*7BFd_w(vFu?C!Hjf!6|=#_mV z{vQr~8a0r0pAz+pZN_0uLY5tGVjqk3 zs*o|(z(-@sGHpzSw;J1-#MH-lSMCv0!Bf85nD8aXlykaSxgLu%=VdIX)aX?FS+|Y< zTux`RlCzIePMHr6be>Y1=W@DQbDTF1=GKZIIYd<`PbuFde zc}XAk+c~^Z>q<^IQZM*$uwvBCiP{gYt+QE|&vxC-`pd?*9|Sti7mCi{*& z<|9VNtb3ESG3j$m_bwb6Q_dZWBN*2K_66ht4rt~sem}VQN_^%;nW&+q-r=f`9E z`y&T_MY9k2qUe8peV)BuTMsL>g}>&IF^G?b-)CD}m)6?BCb_~M_i?G-3O``@Yu;-u zmOPMmbDQ&yOxT)V8~#xP`Mpb=21mm?b-pj+>lGo#-ud?Lh_l+dez>H0Wn zTG#K^tY*U%bHSaC*zxy1NAxL1C=bME=Mu%i{yY~<_SB>7(d4>gFR); zy+`7JGwdp3jy>+LGT7z$*Gh$x;_SqK0mtwHlP<*#z)aLTtuwZ}7*wVH13>6MFheZ8g|Ij)t~ zEc{ceIVXoA7pJ1i@CO_|ToCoo`~B6;rRQN&T=k ziK)jCpOCTk zuex9x4Tf*n1>0^g>P3t?%r=?FHHP!wTfg&f>xtn7u?Iu;(mF@v5^ST+Tl!ufzpIEG zyl`$V%`?0p!#K*YhT_7cO1un88vEn{$O{3Ax~a)f{683+5y@ZCKem^$>w zIHg9^hm)j#A!8udu5+!O=R{twt^S;s=0w+rax~WHfvIv0UDm&~BDag`PswX&P>1>x z^S9_(2l%?LAMA=*$M`J9oPtaBICg#f*h!!Gs$8BEU7uuj;(N5KYi_c=yiXF(b}}A$ zS$h{l&bpp;V)coxQWK1UJbB1-z3T&rmUYNKW489KbH^!)=|^iH?%+bBb7* z`y6AOQeVtNbg<)^BIce08)Ap59yP8Wwa2*_wGW1#nDwR}$F7eb`4gwgX@g_e$B+Dp z)2O@Rf41koKrGu3vtEpwW7kJ*HTMVjk8S1Uf@9Z@%QK$xH7~;&U)MYI+NL~Q$r(q- zY2`1idl3C+9Q*VUTVc1QQM z^|&U7zVdgZwLy8tExD}kZP14suQ~fd-)q-p;hPw7A`|16J+1r@3v-rpW9Pf^;S1;P zBl*r=k;aj8pQ-X)dHjVlpN(b=+ddnGyNs##sg>7^MUF329=U?0Jp$+0uJh`EvijM^ z#9FXj?SRGqXPd9PyX+6Bbz^x#$nict?I9wEQLo>>x?f@J$JaXLJ2@c!T(8t3W4^bJ zX->L6fM|>K*-Uv=4j-`{{#@AlLx0!vOmg*!uaTo+9pn1Lyut_VY@}<3HPH1T&@{)J z^84S%jg_A%zo~WBk=BO(IZyQudFMT`!6$2iTv+4AfZkp)xQ1avxv1$g+RL(a&`1w7IIu`Ho_j?tw-`VkMpp%h9Lt+4a75p z~W0_P8;|Tp8gF^X|4T-4v;BgTxuJ29IJY~@^V&BP|LABgXahrTk@#o_2f z&$OV+xW5{3Jp5^Efbrli`(h1<8GDU)`8&GkiE%uW8o(E7 zKs`CXqwe>}ADDZ_@;jP!zZcL2@2cZ?4T@vgv!KWR4xiC|bv*OJACK|1VC?y@?-5z& zCEpKLePWe&u5aWPoILK2Blrhi{^ldvx-)!h-yvTba<8s>tjYc!A3E5Mn9FlUzWaN6 z==i&B;n%KzW5>PM*$=O_H?t*-?*d4#>jZ1&mli9OsI|NHwz==D*-7sQ0}-{1dY z7<|V&hIYTsXvvb$#{4?YH10PzQKMDDC9`k6n zll1#I=U>2a&*JzW2roFd?NoiwXYXH^e}9!%f7YDe)7YEcvu^*Q|GIwuoiPwKy=*Uw z#4b-}vvr7|-K< zXZWAxfA9;|&sjHF*ZiCx-&xoF@Js`Y^^yT}kk+|Ks={{H^LkFZ?C-(z4Nvv$0Z^ZWUG4D3m77?zo`j!{FLV}tp}`I}hg>fYe_ zao)%0O#WU2`9BoP<3{l&*1r1p8nFFovCP@;zVE~Ndku@hX1O21e%LH~>G(QhkMo>3 z+#7xn6VLKWeEq=FBKlZ!iIx2%drRcLJeFqnojj+bpO*g?!{g&Ja;o;nK6E)|mQgRb zDEoG79IiV1`hjOK#|k@$lzlLMgs(*ppTRhy$9fbzmgfw7*Ct0~u1@1+J#3-}|5?v* z-T#4gef((J6GIm+!F?~?{=J5R#dFFj8~Ao2Y`{6sGm+cZub$~-44E#l*-rYz*NCy? zlse*GghJIfjEqF9JnpU+GRsO9*BM4*=axa?vJn@ZjbvFTGz*q+LY(-QoxD+zyp3zkAE3!2#uJQ zwxSRDu8$wJS&LCy%Q*}m)SmMVjQpZ&A2QUP`E9XUYh?`ui=3CWhwtc8XZU?8?9!+W z8WJk-xvt&h=hsgE(A9G6(m)+RZQ zI#{mn*eZ31pMmL{#{j(Pfx0z3K%N!cZE%kUhFdF+#*ND;)KU^!U z0s8aW%vfy=oT~QVibiaCjWA|wfca=2vYf+Tn^)^1;9`?wyGG1o%>ClE2H=Q3YGBMa z*9gbUHt^-qFrvQ~Ej^b=nfRnEASoBBHq;Mp(E^{duGcYT$Pw!W4Ow%G4)t=PsTZCdD)*?M!1puE zP44$%Zl8DY<%{?>w0(WKcE2k32XCdW*|X;Uau1s1HyS&kwu8L=+G+c>W550o+n7JZ zI@S;0x!-ZMqka3e)Ann}e*D3A+F<`xg$L|3{rAaFiFHC=9=IJ1`O*E?RcXz1{a|b| z@9&qz*R{1(IdV(8qt+GiyY^tC)I@#Xhz#p5Tx6Ngh;!f3#dYTb)+-vno69)F;CS(U zbbUuAU%%jHX{U0B%u}j9-{*}lrGBE18kvUYqX z>RO-Z0`jyBPiufr+EoU69pkfUX;4^D8ctej`A(V^kzSPba2F*vVw z^@l9^(B|22t<5Q}wRtvNYx99{?VP9H%r`X!PJ7B();2n4%C~ih&cVuY&Y`u9 zFJm^*X>+AbboR1IKF4hCRokTos-NhdO>M_;OTIJL);4(N+T83_+vtqNuvcxPGscT` zcs5*Hhf`c@^K7`*<^$o{+9pQ%W6v;k4~O56uC?zTUZ1gHEtVRq>j^saZ(Dn{AAjvf z*V^Sf{nm>8+M%EC_G5=V*%i^xn&I;}`WVx-6CL8pdpG=a|8?2OzAjeQ@=Sy=kYoMu z=(U!;9`!-n*RNk|9S;?F z9C=zAkNm6W?{e#lnB#qFaMfZy*VM)$#!(L+T6ukl`MA8-;P>Rv501(+OylRsyd}qc zU&>XV%{emLRmQP5s|@GVfOg1%cGUMdwRVuvj{4f8M#%^MItTbp|3=df=hk|bCl>qT z>$0Y`182EkJMRi0_P#+`fK%jxfG{a70IcGNwP+LQa2>@p=Fsj6-8wVq}iMn>p1NWUNp0 zkoSnJa|HPuL!KiUb3Err#+)OLy~sAJe_38%q8`f}Ax3HznTWm25##4*ZS#F8SL3%P ztZihB$C~Cm&k=H<9rfEel5?xR_7?HX;dI!fwSRl1bjFWs;&UWE+&7>{V@_D(hj$jG zj~|!B{C3XqZ%IKMlYDbGAO0`jq0DRSV9^ghRuS% z(WN%JK7KT<>v!wM@3fWc4&$CzeZmIkV%W_X=Zl(m@9Fh7|GUNzTgveqD*M*T9SZ(7 z8ctdsSs#=+m}Kj-yYk%Pk#!vWY4sW7MCg$VeQ9MjD<9h7NRG(9nEEBB6{B{sEgu~D z_s9F=$XM7>MlLvJ8SE(o|K@=s@o>bhGUnSOc}E7jAyeu}f0$EUA3vJb^}B0AUHxPp zA=kF9->qxSFmA3H&c(2sG0qpYp5$GB+d5Is9OzpsH#JYK0rCk~jMLVDGM`js7^9W3 z7PP~$trPV*ikUm(rFd3pUBXk?-yB5+BJ=}jy1iF z-*`f&T)XnM0SsGh9l1sXwAQ+QcO9uKPY+~GBVMl&&I@Z0yBXvBl3Ih-)|9`>nfoc< z7S*10puP5)uyXvqFMevTm5cSS+^51V9JB7Vd5PLB`Cu&b;B|=glYC;IK4t3qS*`>8 zw{P$POg(I}wtaHN)W;9!fqEPn7Y@bL<0xkR$_+>Ifo;aejxzGXG0R}@(t2;?`1oy~ z<6?7Ba}oEDOZ#0P9L95bKLIV}9v$ED0~=kxl0lZ{{<8A4yeE)zInytWQ+zUpn4D!j zITJH3j$+1~_3X>?a;hABz$tOGysHNnS%!Y?I9EKHPwXq>Jn?8gvz>Vz-!)2qu&W)f zb9qg`=elN=>z(y;LFjdT{AgO&2WQde2RQ5(UB_m<@A~-Bw60(2p-*!?VO%(>``=Q> zVq@#jCAA)nWy)XWjh96;qF+ zm^s#ej(M(QM;Uz|TQe@L>w)9rw|$O_zl+M3HI97O@1Ez2Gh8?1#Bn*wiJ0Sa6qA#z zCnsWAPt2IIo_$$f&XgnGJeTQLM!mh(1nbqK`O0>zRgdNi`;O&)CC^vpGIo{OAK&TA z^OAGy)@Om`Z|zhYWeuKJ9oCAeJz&@`_pIpM{{638<^tRLgFb6K*FEYbur>SmuO0B# zPSq*!Zd~i>}%0 z|M~j;D)oAC#0~HJLVjNyxnD&ejC-(*9ZcAEgK^)CUiez}5PZ)(W5>F+rif#s%G+lR zi!8G_B7=`v z=5QkuxhucFhYY{R%(3g&b4WTPM}=SKBlCcJEab649Xk)u;d z;FvMy0e#E^WX1A*o!GuKx12Z575$YrkJb=*K%aWlPx{&#GDjZe1RcjPM;;vu9cAp3 zFa3ab{U~!Q>W047565kDlj~g>bK{uwhcEjM)%!Mhm8bT;E#~O*zHP~KKg?16bWh~o zm^m)SJj&PgJXhf-V>t)6F#XH2*$2L7|CkGM3va|_{=xK*ctUTXn-M&{eP##j6R<9oJ|xdwYq>^-ljIpc3p zE~1Z`4xVRSKRhQIDm*%(9ja?3-}m(;AG@AC;umWG&G~^WYtW+lOl~v3_%22s#K@iN z3h`J=$YYaH!l%LmYrgVg;Y;B`J&s);KX%e5zAERnhGW;qkDc_1uUAL>*$?cs*9v8_ ztyU)Tv)uDLH@Y5!nj?D~}~dNeS`RUV${gReYJoUS$Hu`7ppoHgM% z3Hjyw6xLzqdLB<1Y-X7}#wAUD@U!dVN7IxeHvQ}RMkr1{qpqRUMfhLV0AuGU&ORgT z6rB3fW{gv6$$brWyv~TZFT#eHHRv7#y?y;Kf5h@0N6flYk7E~lv;2uu<+Q=E>*GiM z#A(!C@ju&RKS_*Y*@l?;H*StyANAEdf2Ag{t-M@t?D}ze##6rLrOaDwIZlYIHRat8 zdUTvM;W+UgRsR{sK7GVS*xfaM@k77RS0>M2WU>#+P*c47vHo3W=tstDfikS!*!4P) z?cAPzSG%1@+EE5O%p>hAM%Gnonrny{@~*4Ib0E&GP5f4#+Jp}=c|cywdNf{+T^~Po z(kH$urwxu>A3t`|C%#5)?#fHMW-60ywKBoygSM{97hICBu8$v0(;v8(bB>&Mef(&e z^?<#+W^(R$$h<2uM9nEzzl(Srd*E*HHb( zzJ23p%)l%ZC$jP~ePrtG0d)>ZnILEd$hzmMix zPQSnV6<nTtQ&B6 z@!xNNMV*!V&LWq7S8l13?BFAE*ug*lZ3TT%^ULw@m!^$7Y98Sr+bGwg)!!K|_oi zcK+SFjH&mj_1Q|sqE5=+=dh3Qxb8fuu?*BA71%j0E#x_&nEZwqroXESPm`>`y?c+9&?N9s^{ z+xXl zL~G|d_N_a(E8{hDz4}mU)aRS@T~;}+c+qX=`olsF-;g<*xz4)f92%SJ#KT&9wsT#* zkHxtTM_lKacVly1p7(>NxlX@xT^WvDKRws8oO3kKJ!?rj&U215=aO}aymp)?UjMaG z6w{A9UiPQ!r{{W>W4t`qGp2vWp?`U++7wqk{UFFY_y6PH^shLD$FLkz#ped}?Daz#Kr1y{qd3>jGoI z>6~N2n1@_*z{o#q3wF@K&Y>#HwZ}eWnZxddyiOxypEjdw`F8^Fhw&K`9qi0v5pgdB zmiI8(H=Z%-i{ohnUmhLih>Lv~e&;dL-w7yx*8seX9mm$+vA-8!>~hFzD~Di=#j)Dl z(NANQi^qhF@riM)ddt5vkZoccnQSxXfx5zGj$8d=JJR;g!Un#(r|$Y%JJDBK)R6l~ zGcD&PL@NOfIY)v1~KUENds*RE9iIANZcv0&54Ga*O^_ zCca)#^;n{HQWFKSJL zGmgjjTGlA`1MtAnar6s6=;O%P=8|hNb~#6vJubL^107poqx@~PBD1rIo}p>yPTo~C ze}m)F5WzomxTgCzIPihJZg5imhWP&Yp8xK)e}5syEdPcu;3ZByz~y_ZpKwpZ55_z1 z|K;hCF!}bG>?}D)Ru^t~alH=q1 zndT3^jE`LG;(Q{1rAF$wWBD7kbq4uEFX!($5z4hO4vqDn+U@%I5sf%Gr{ROXM?7c! zx9}@}rH=Wot$qFOIzCnXL8fpWNBnUA5?>3a)bXgTGvEQe%tOQRS;vQe=6Teoi9EOQ zwexh;)>*`dPp);W2Q)aRomc->u=y;$^xbN0spD|}zEO3t(fVvIv2!ADxXAOv_1^0@ zxIS<%f{*vASH}_O@5&-iu3}v}tN-o*di&#Zetc~Be(llg7e|jDzxleOCvUjq0{#U$8EJ_pz?X?@aWo=JA5VgZ}%j8pe6k zpAP5!pAAR$BWvwL_x<<#K6Kv)SB1w4HixoxP^P-%!sF4C1pp z>p6Qj+FP6RyXrZ6F4~_q`tPgf>}zQEH2Cbt2LBxIqUL;2&-M4xdagfDtLN;uXkXIk zKewI_=xp} z{-@3PyPNa(H2Uvr&fnjhf1o+Pr#b)6&H3Lp=O3)+@-_SMp?*DoSUs1I3!3vsH0Kx9 zbL)T6oL^qgtN)RdX-69TmU_-RRN8Cmc|4OY?aq20&&x}@tHIyj;1}FJ z#H;?L_5ATw|EhX^u%6#i&lmOl4fWh-yKk-MPpJ5N8vWTF!+2gFE~@A9{mJ#5`ykq) zk-xm2>)-S1x$Du9dM^LB)N`JB(st^(@w};?8~@wt`DYH|v-j8YY-8uL#{4k5bcHa-1YgT^?bM!i}>of z>u0-OzOIqKvz}}Ju6k~Ly{Deb&+K0e{&`Vv?YHY&yFRt^yIl|7TIZwdV_W}=I)3cQ zO1tKoYmeS~?0V^&U31NK*B`s?cpPszdTZ>Ps#u)gdh+_TkGkfXlP|pifhuv$H7`DT z>&bln4acrKdGiT-!tQZTuDRye(Q9vT{9C{Ah8Lf_CibbN#W>~sist-tmgl#eJn_`e z4S!A?yY=LW8?HO4-fcG>J$}PWZaQ{-=@2Eb#ZAY)v8vA&*B?86?Buc87e>c3o)Q0^ z|5gawefjne4Y?WuZ%C7fnOG%C&uT=@%ha7JR_8z9tNHe z-(MM@uZ_=(>S+gT2UvbN^n_sNh>ceV>Eze9hZ#w#tW7ppwsjb{7sf@Scbr*9h_SfX(zu`Gchy3-gxasJP zF*R4uXx#LDrHj-6P%_~sJ}>q7kK$zw0S;bbcREERT^#k1#dOSv<0j<|R6ct7^n z=KIN)o;VgaK&xp#b3I#ZY%Ie3>ioK!uQ~dSM{lUh=(7LmU&tyJYv=Q0U0Hn5v0JY@ zallXb7o%EW{urJoIUcD$8UWmk2jVE;!Ewn^2D)Y zrMsR#``F1B96f&9&<9ZapO^dD2hjhe%QAn~i75D3oSzr$#>0^>emSbkhWw-BgAO0| zl^nT0z(ld|C9KDzbFV~ z5AoO_1%9GECqBO)pPl$D+rIdQ9=wN2;Tx{u5+3pG>eBvb>py@6=BmhW3ZQ^^4#4ec$)(Tld`eHFtgP z&eOO4^7&Z#xvd}iivM)}wWt__?A20_FY@I{rHv7 z`muL@&(^2>^dFu7DZlW~x8CsGKlNE}`=KA$ntjg8ANd2PerRjw&mRBnfA*Fi-a7KH zKlBU#^o>8d^+)f!>!-f@JAQoYgYUlLZ(exvj;;Uj=XX8w+y3ECZ2juX9`~|;xBZh_ z&%N;9e#h5;)|fcKQ6LwqEwWH+((Fq!Kb|bPoDWRTlZXf`>(wFNk6-__1N>a&i|C3+xm^GKL00Pc;3I-`m(S8i|=^X zJ^yO!_b&dX8^7V-{QTA>Kk$@4{^DPM>(-k;XL05BPyWKzb$|3f9KP|N{o>XS|37cJ z?5>yp($=$n`>Vg}`Ct3XTVL}t$N%{6J?md@{pG!zH=X}kzf%49)vaIpzyIx1zWW2e zx^>fwe)r->z2t3MFZ#~AU->P6^0uvix&4xV{7;_!Yg=!6$&>$R^XGqU>+N4Pf85Pa zIKB1w|MKa7``rKe^w$6Os`owiPxk-%*2(Ysw8aNs_Ul`lzx9E)oc=$4ee2V{`R&Iq z`v36sO`d4?p{CEEPcej4*=f3NWU-8v%-}>X%KJnzMPrrTZ*B<-Yt6zHP z9a|gU|NX!HU%%@eTkrnO17H4mfAfy5@BXFdeElW=@b|X9`2#=sreFTG->dw*bL+@g z{fp;zuYKp%Q(yG%x4-$GcW!sDk=yn;HZcQ(lvkq zPy`hf#g!}|X(T8ZRxskaDrU@>F=JXWV@B5iMlb@RV%#ALf^e$3?jA-SpYy!uJMVSQ zcU`Bn_|LEIN?l!D(^FN`6JKh!D<6$Gy=g+^qkQz(<4FB7l>(HVwMJ=)T>;YGd+^}o zumTj@{gHLsi~`iKG2(JYUID7w>9(!8t^iF7^8s?0@mdrx@d4Vk_?7OYr4Nw# z)*J`%nFpwEr;@X!jJV|MYu=u1qnxvMlfsDo~fCsHQ(cJCbIu^S}5AO;ZZ!x8d_6 zWL;;yy}AF`UQ8Xx+^-mGiBh zpsJEK@uwy}K}YpAcg|b<1hH!ebUSzA2`bys`@o9gC&+GfQmISZr${aTTZd68=s=BE7oVc5N9Q}%eSC`6Kg#S>ZBU3hEbIKNz@-pnYWCl> zDy9(mxm;e{e^Vh^cG5TR^z}k?Gstd9=ZZqaW)1xmW>SP247!@E9bSZdN9#K8iYr3P ziaVsP+ggNzg)fvM^NP^Oj7y)pR~4akXC8;-c6x?1&b>{SxIIH3iz3(Wj(dhYgqt>f z-tr8UW&2(+y!i}8CJpb`v*H=@YrLS{x#M#byz`(`{x z8i(61E57_3&FQ#rtLLZZ$U>%Hv0d*4@;rQZ-&3a-s4lkoP4(0l(Enbb+p|0qigR9| zPgRRFkCwbZA)Vun1Z%uRk3TWLH}!jo7Tze`G|%rPV*C7I{(iwrNh33N$#g=zvbLcec*g>wHK_F&`H zSE%}>pAt0G$;{l;r38I^T>apx zPYGN{C1}US!gC*!OOWdQ8l9LwOHk~su#@k$m7wiA=brL8R)Wk2Pw$d*wFI@1hBb+v zl%RZxbc%m@2^y)sWW=uTB`D_L>Vg+qrRej;&_>NJrO5Tt#i!!FrO0-WTkHteQuOz? zjC+3LN>Q=cRuB|cir){Gwx5z*iWaEVw)I|Cik4+xX&Sh-6djiIkm(;SMSrds^5sKr zDcbhJMsV^$DGFCAvr8x|MZ?y4Z|qfDiY|)0`ae~BgQhj@)0t)T22JnOevoeOH)!3` zerIJ7?_dH7|w{~Pqz@ZqOAOn-ysCHpp|%zuN7dyRisyygx1e&Kg^ z;GQ?AdtP3{x9rvJw`$R)O5p2_3xuY>-hH+h%A_)vxpALx$~;>ytLw2uX27nC8j z#+?g#tt~_6*zDixc9)@q((w@&PL&~tuXm0n-zY=Ok3kOBPs`9Tztims-_O3x!=T2^6^W;u>37n+qrwEz?w4$FYTUuwW4QmC(ayl>W8h$wFgV4 zuCCX2j2gm)F`}aM(6v@BK~DukyKU;w!@_Gj$w+CP+U2F=%}n-vzaAEy7ylyq@TWL( z)5ToA7hI>ACz;nh zm_FX)%GGE1#!R%*CQImg{J{;1NtcWt4C=BGJV*P0)8CpWYb8Z9lZ{3b>rCudGf(tREIu=H&#b_v)9t4P;=A9ncAb`o zT^(=QWv#<9JN3=N2yCE2cyH(1p=0Ke*nz3{EPZ0}E^Eu) zV|c<0AB;!-=W1D6_j%W&HGE^0FS%a*&AQG_*zT*z#LVxOvg*5NkBw30^e5++42hU4 zh^ZHC9dDeRxO*ku_f+!K6x^!xPrFJC(vrJ=N3n{)6jJ=SHJF zCZ4D7zLIWz=HzT}derUw=^46ZPtzSFUZv<@ee6un9`BB7kX|P0JMN(R17EGZfAWw@#R;q?Xg zzW?$3?yFIYbHm5&_$}i2(!uS`rVpKqKaM$@Sa@`ZnQ$cN^MR$^{(0H3^RKdhN;*_t znA$%j?c_k#D(BLwSBH#*A=QsYO=}*YJ#BoL-kIl;+m5}G!Z-JeEV_QQ=6pm5p5KH~l#ivL+_J?=d!PW{2R@?>_l!{CE>FsJ#DxOqHx9 zXZqH<>U8P)+;vjn`kIahcN(E^^4Tw>Y3{{7%C|;jf2k>XHfqPp_~+_nTfTaQwcW12 z|H-LSwc6|L<5yjA|8!?VZ~t}3UZ?iNDpS3INd>Q63NF9u#_CUaee@6iw>2}D-G0Bz zZ%m&yPG0X_$Y)(f^s;)4`q1A>TuvUbu|2UXcGq1LGt2Vix0#nmSn$)YO4)vH;c;M-rktM<{dp0LBX@ko; zkh~em>PMmq<%@A4hFX{HmNXnp((8G2bmO26pUm~ey+>+lx7p!1xXJQtW7oKPBY*9(Ij-k@VEMs8A6;zMZ`l9(wV>%?)q@nJmv?4J`DE?awH(;eMuk1sE`<~oc>x7=>l zjcq**sx^>%)&0GDX71{Karb<`op<`gi3ZFYvh`w)?v<=j{YKt8+<)8ovrUI)^y@m( zrr%v%_T;K2sa?_JbBi|Ux%@D`Gpz2V{ksQC)FL_*C>b|ZoMMk0JaaYntI*^keG`=& zxFSg{nptP%^t5DpMxXbO)q_XJy)?=?I&4y}C-+_`^=f)K?A~%SU5mpHx}P>Wr+lsV z9o5sfKOGzXVubqE+e$C4uNeKVZ_40Dvx-h~aM%yK$5Ur` zmz)VA6BL2jVa6JQ2e~CG`Hu1JpCg;?yGpkn4=jBV-0Qq@kF}d0O9R)NzqxWIQB$w0 zRi`ci({yuoIzCt$Ker;__0!uodcTfP9k?XN?2GD}sh?upjbfiKUz0dLVT#?RnCpE{ zr`uh3vCLbMe8lV(w-6cPe0fvadZRpB`Jf9Vf6K*8jd2}_$B__CN!X3X;13onAMg@+( zX8io@x>H+6RqXJ7U;kqIykNE7+xxuQ+KqGyMzr+8l)>W%&U|CP>u)EoZ4Xkdwtd^3 zaI^mEhW94Z?92QQZCO^O_Wg6(<3pDxvG?vAT_i6f-hEdl1jZxN$U{tgc z8I`v485QkxMnz{kqpEX+Q59WeRNLKW)Y=u}ExMJAny!jKsB0(?>RSth2Cf2igNXw5 z4pM=-VVXe0FjJsmbVi_I{7|6L@vA_yqq>r&iMf(yCo+zNU|){kxkMHWlet*HNL+N7 zl=T_-eY8e}xxLAe96~fVh^2A@_jrUTwBJQb&p#WSx7T&`g_rW7-o5l+CbXJAVd zu2CqC>40Cy8~6`7Jf(53wt*)vs#cK57^f5i^x>ALZ^t z=03@?Ml#k>&ZC%^z!+vMenI9h<1r9*A2pIso>;ugDLVrRUw{t6q3IJGXIE2 zVB8xyRuw7` z;5cN0lTpI4NDAx;VyDWe3OUaeiG$VDglcMPSg-TGF%95a6|NSqa|7~eJjWKVC!c;5Uc`|4J_rWK!5`Z@loRt$6JxZhBx z4%+P(`y@%jl`q$3Od$^WEJ@;#aOD;JjuzU$BFtiicx1hZAB#fqI1|G40X)wh8#ffM z2YScF28YJS|2jX&MqVx{@86Z1h0C>s1SHeBWl~n&LDlCxRX+dp9Ljt?yz+LQGg{T#7g!z-xUg0IbMxr_ zm}{VUpm#v?K_7t@g1!JP1C@c+feLTYeDy$0L9IZ=pthj)po2jrphH02Ku3algZhB_ zf(C$wgC>B^0p;^w2>d7LTF^|;Y|tFgJkUp=g`mZtGEm`ddfa@UG6XgSwFDJ|+Jj0! z-9WuT13;yqDWDmk*`T?gg`mZt<)AXqI#A|sT2CRU2-FbN6x0$_3~CSR1{we=1?BTk z0Zs?a1kDD`11$uVfhymj_0j@01Qmn&f=WThCdM$Hcw@Q`UYDJK_aga(Co;q0BABs( z2@JM{1mZVkZ0D3*D>z;}0US4mzM@wUP8Y`+Fyi#`_&A@$I3N%FzCJ88i17@JW1OXN zSd$qKT)szQ6yuy2#*7V>GG2It)Tr1Q%#ct#d4wHKgZBa@lXP+13mk^i2Zv+Dc|=5! zIEgXb?GDa|+(MAN660Zm0M0WZG&(4hte+Bx^N%Hj%TNp^<;mIE6R*!>Pn@S`pOBC~ z$y~mKL$I?l^~BDUyhz551T$`K4$;vLWQ3(|EPfY`!7n}W9T`y{?oN$iCg3|Wav$o0 z?*tRE4#W3`5m?9KJ2+Bhnl1zr&6lQcVVX}o&V$CF`H}VdmN1+R$4Qi&3umlhSjXaYQmkJ1U5Omm zDBKP+unxiDHVwgllNIViLdZWW z#*~Xg;+e`rNK6{rjd8{UGCT`%*_%r#s`94%kn`ekS(#o`^Y# z{X^_4u@~XNv=8>4*hgVc9G{MS=$V8)*;TOt`%~CI!oCXob~vsWdoo)X4e8SF((xr9 zRCJGW0BA0#=speS_qTZS44Mxr%7^rzxuBu~2nXf&$0Px#gBF6~>T>Qs?SCP?oSc2~ znJOA9zYB)$qiMa*<=6Xf$fqkl9oaq8k1rhSe=47Rq6@~cqHy`MxVl7eBZOjpeg9kX zBYim@=S^x$j##k-{?73~*Oy%Fq{d`?A=gki=1^Sk5KMfIr0w`N_)o`AKjV_muwg%? z=WFzz(2mfVuVugfRp z@ptFexb6vfoQucp7>CQIH6owfXa1s2zs`@O4aRwp_l^WSVp!wcNW0K+Z7Q~qJzk`b zlXst=&s58?l5Q4?uYA%LKkqmDxrQM)AJSuE@i_~^f0KSuPrl3kSMpOlVp3;1!$E5L zujhpHG&0jccEu^~M}<2fEl1kuSEB&!$^7}@%l!Gc z=zcI#o^xF2u!yKoXK6&d^Qf`z44GOayM@SfCee|uo`KS_+#Y6b=bAj6Y>p)Qt#NPh~Q+rTMj<7`Gm$rN5pX7AM^}O2#+PV57-ItXG%3(Mi&M# z^O$C)Sbk2}*NRtwi=gi7~V6E78k2rJ+M3D^ba-E;U=XRU*wcWqTJrs6_gw`)V|)R-tBt zkmJI^Rp{^i4g>d1uR`CpzqNh7uL^auZ9j7Ft18sX=37?{gKA{GG%!Fosv6}FzI#NaxudU;I+t#3G7N_in$JU^pK5j474%Q&Iu7~ww-`Ai; z;~cVoH>*YF-Nwzg52{6X9p_EmmRXDX7>5o%{<;<=_?D7rW3Ue9%$^RQ#BKQ=q zx8oZM8g<{Us`MMuy*F~>Qu8|WW%ZEFMd5Ymd1fz{w}g}(i5_EfF!=r?orc$;zG(WM{B<07|xM~5Fy+PJ;^J8IDP z%Gue!0S%whP&s2>1A1_IM0)2t4QR`lMK3oSHKGv(6KpEN8c{_0KQByAH=;R9)jX`# zexN108(tXt{y>ZRJ#X`F{}1H9d3OH|jXzK=TK3+?y9qr@-@Wtb?j~e0De!B@?@dTD zG{$d+cQYzX-)t$h`vcwXl=(t% z`v-cSdS*tzkss)fd7ZmguKs~+t#W++n)w4Ak4sEBH2DYW^vD_i|ACDC8h2S){6Go0 zF)L4L{XjP^4VfHM*@!eZw^=&Auo1ogTd+R*Vk25rGDYRY?ndPDN5iBZ%Nmi%&FT1m zBUQ%5zRd#Z&VZUY-o zQA(7=&a?sT?pD-ygn9$AJi6oj;>z!6O8VM)<6@PfrnC(`N-dSy)cf+WOv3yEl4pEm4qAu4WC*xfj?#F6T zdfeE8!8>YEZho4>@Kx9zo@x-DT8oUs1a{k}*P{Avqo;iitVO##92a?wsYM}OH7Dk| z)S_3pf8>nsUyCF`f|=jD*P^vY`i7s-t3|2?z5%H!wW!g$r)@}W4SKJipW}zeu|1`q zuZ9)Ypb4Tiz0+>hpjF?8-#CA^2IWo?M5-LBK?m-1uL#^)gTDBtiXW_~K>;ehg7GOe z$RW~u@R#W|sC3Mnvm1hIV2^DLYMOuUo%!$@^hu}MO?^-e8ljRkzPVQo(yuH1sMe_l zUBA|@q?-%H7Z%T_a%Fw z8u?usllkOmHS!S5I6QA>H44i={$|j+YLwz8=%$iZjb`f|8D2808chwDXnQZZ8g05? zbMxJci$A@@Cna?Wsb+^9-#_Hddh(!l@6tEys5IR9*YjD)e>etpdNqD&(g2s%m*e z6{_pC-T&TiRp@e3?*@}GRcM%Q@uB2lRp|Kn34Ub`Rp@)CXdwQe_bbrEVw1*h*DFxZFT*4r z=PJ;fvz1|Uj#Z%bvyB7K?5jZA+7%lKwpE}lJO4}>yS4&7@14FN`;Q9L-Y~VDVQK|h z_@txJA2TaZa=gn@)tCw-=-24IJfs4(wMg{u>{o$qJ^r!l%;*Z_cvnN)->m|f4Yysc zjVe&PyaD)s1v-@bW$7}j3e;cx+k4;c6{v^XJ|nR)wsWomrHBL<8`Ln%nnbcDe7|FU z;`^5x8A{*sF0$sc47CfGKTfqwhSm>TnPvK1h8(sH7(e8J3^_hK?=}6F3{7`lws>DI zzHi!U{`uW$89MdZCa%v>8QLcb*GSzfLjenp?tQ*hhJ4fogU~t|Dh*9Maqv$WS}1Z6 zc3p`1V@}WQvt{VUx8WK+;$^6zee#x5(`2Y|f5ijOAQ@Vm7?=4K-8$SQ^4SN|fp3>#iDzOZG=sB}7#8QTY%1WscQyCgKYqXu2A&$>E z8A@RUO3Ffhy^L4<{esw;Q^{Q+R{rMj|7kU4TK}`e>mBr8Ys;_L4Sz%AfA()7{)R^D ze?P}h#oz7V{|T7?+CRk{<})q-@cQI`=^vlo^#AE|PYymMb^d~dixw|QTl&YcKbNmq znZ9cEnzifJZ^+oVY4et?+qP%!*tu)>p1u3B_8&NS=Z6(o;!cx z;-%cnSFT>We&c4|t=oU!xqI(^e!+u>j~+jHT3Gb#`HPpYUKf{?zA1bA?tS@(kDoq& z`TCEnqOz*GruJK1{r85(A5G2pGHs)*qN*lT*U;2ztF0qy*Irjo-=Kq`k#R?pPMy1$ zcJ0>P%)Ez%WzSx{t*mYOi2L^IZ#%$lp#2~RNBkW)Bnb?{Ux=O>79KGzGAcSIRysW{ zJ|S_&%%tR5ev^Nj;vewu?X&01{e9m5?c@KyUH<=e`#Zb14jDRZxSPAjh>@P7yu3$` z8S67{`~=^LlW6<@d;9+{+FuVwfXp&F|4R2OGfdtK%KUQ*+#T2r*bI0i zusN_dFxkjToG)-_l9Y*x4I{oUnfZ&4h~bt}f@9-3O$ekMhz%rNAU1G{4ZM;WvH0n6 z+yV-TMxqdNa45I@#!QWh|+>zTv4*#_6R9)a&Sc5&X(CMQWN#CBO&qcF`}B`|+I+5?mMP~s%O+_ovqZoop$ir3D7 z`Ta4zz})^{947#nzdpl(`Ri8-%#R;Q!2I}<0?dymX}}^VKOLCAelvji@gozMAJ4LY z`SCIvm>(~4fcfh^7nmOp@__mABOjO_Ukicx@wFJ3AAib$`SDo>%#VL{!2Ed3)Y9{5 z3dbu1=Eq|ZFhBlWgMP)2KZaoE#~)K*e*Ccn=Eol~umzN756pLT32-m4y8-tG_6D{B z_64>E4gl^091d&;ECn72oCItSoB})uI1QKuP6y^EOf!HbV9x}02F?O@1&=7fX9QK`9}K#x&Db00+Y31;zYoq z6d6O{dBCQ?ct0iQEP<7P?Sb0>y8$Z$^W&BZurJtEfy05-fRliQz-hqh!2Es|P2dc$ zYXN5gw*}4t)&|Z4)&VXA76F$7w*%(K|&O^??n64S+3y4T0@} zjey;Nje&iEI|7FTn*b*PcLGiW?hKp(+yyub*c3PixGQiTa5vyW;O@ZXz-GX8z~;ch zdV2glfDM5yfGvT00^0-k0(JxL4eSeS1so1+4V(hp2RI$LA8;nHEpRsQ0N`9;JK%ia zfxyMU_P{dWLBPy+db|$6B49^gQ(zWY42*y!z!G3@U}xX}U{_!%@DSiM;Gw`7z{7yE zfQJL;0J{U{0eb)!0*?SL2ObGr2RsT`*g%ip3)m3Y8`u(f46r@$cwjf+3BbOHq!Rc z1vUiM1GWS<0JaA<26hAP1RMZt1uO-21Wp5Hfir+zfwO=kfpdWca6^?3tPETXtOhIt zRtIK&(Bsnp76EGkn*tjHi-8@1-GC#3eSrmVgBA{~44edve*%YdX~6hr4LO$q3_t0{ zWC3e|JqOqrI1ktnxDZ$XH+bd1%D{ENYQVxKdi?6ZhQJ!YmcUxT_Q1x#ZorPf0l)&d zVUz-^1E&CM0H*_M0cQdm17`y}0_On>;D)jgSRJ?=SOd5Y*ce#YOpn(Q*c4b`O50xy ztPU&z)&TbA(*pCI?*DW4uVg-;Kh&Zh^?- z+ycZ!LOQYwj<|40Pxf&U7YXqrA-{NTXDF#B%{KwcmqI>aP!8FRMI6~lMqDJEhiG|w zPG~$k2SVJcgO9Q zh}n(&-VEnRePUaG#3QXf7(>4&8K_~R%2(HgfVpMN}VZDQx!gY-{pTwA!jap@#KA9;SH zzmod#`H_B0jt_w;Xjg!%Hx^-Io6>vka5vts`V;*PteUgSDgtS7nN6`5T3 zKQp~P_~RnufTBH`JO3^Dlkq{(9^WcIGG37L!si#v9p`Y|qo^a}h~jx9<2Gp-z63h1 zkRCUYOF+jLk|XV5)A8|;mT`eILwCKloLBO_UUDAzcBbQzJ2M)}r{jsM{Cc7L>|5$b z_CJ%-d?7v^Ph8~Zg}m>O@ru9R==jiLkKvAAaeN^0y%qWiX}_^Jgj^*p{hMCbE#nw@ zU*N9;S8lA0;%Y?uSHD_viCZ>%pH{S`SZoJ!$*9%ljcM$6Icv<#@u`CF3^fXTL6oi~xKOrRDg@ z`wuOr<$9v!43QtdV!l70*Jy4e___XG_)4OV=If!rq#ee~k0%~>rt>|6)@!2NPSd-} z#}U4r`S(?lo}7uF)AOSUz0V}=!H)}k`|;x~Ezd)KoU}Y2c|W4@o#p41#viFrA5!`x zZk>f3F^$h(ZT$5%Lcvbrd&|cW{&@KJd78ho!ts;#8YjOlsNJPidhb^D(enO5^XErR zzJ6on@7vVwrqB;ad7~8G_X&H;kH6(UpT5(Rexd02d1HRum%_ZX6-1Ki(9aq4ZX3@- z!#W0^M+(>rAs`+28E_`>G2m?AOyFGLmB9JH_koLn9|OyP9{@8VTEG3kBH%N?roh*L z#lSCsCBXbTfj96Sum=EN0+s^j0sBIG^#o1<`)1$_;7DLYh~E`B8|=Zn9okm_oC|jT zJ}@76F4&8KPXn7mdVZa-4($B(V-I#^h%anM+mFA$F$7)$b_v8+0k#A?KTf-WonKe9 z2m4Nl?*_aI*cX@|m&1WifISKLAh03K+w=E}X<%Olc5f(O4LAer{5p*<*o9!v0{brD z9N@dadBBH&3xRI|mjkZ`t^+;`ENoBP^E$90@JV1x;3vTL!1=&#!2JEPFK{l{!+{?H z2f*>E11EvK05}c!C2$7tQ{XJ%+rT-%r-1W-F9R0>KLRcX-VIy_d>B}$OWWf$up#go zU`ya)V0++mz;3|zfPH~WfWv{G11AB$0!{-i0?q)w0c;7^V|U;zu!jN%fSq3#%>jE9 z*d<`^0h|Z+aNuw_KN`S=U>^yb0_kml%fTKGEQR!%z;$3>3oO*5?Yjrq5cmkNB{2W~ zZV&ty*xi871M};U{JN_z*wetyuZ!~QxZz;u*CCUjzFH7J3GDpmj5IjjK44D+djfDF zq&Ec40Q*tkEMWfrAO+&L1$z$Iy@0);JWJp_uulUPfxQcGA=m?f(;$Cs;Bv6@({Slv z*8zJS*w+CI_38N;4V(e-MZkt&=RbERL4B;jZV7h&T{{EpeZg)I_9S3_9ohld4eaxQ zGok!;z(N>b5wI`BUkV%!JeE%n<@Wma@pa3Rw(>XeSm#|HvnftdR^dfurCMBh5Fh7CxQKU;2cP=2b>1>@qBu) z>jP(ieFAV6a0YM=@SnhWz}tZffwOo!Y~T#w9N;YA zzkzdrZvvM?{fvS0z&;t6pVxl_TnP3nz%t+qz(PaXUVHiUz#IAaP+mu1d$4Z;b_4zm zI1AdRKd>*@X94s3R3yORVE+R+AJW?cCxJZ$xDf1vfYZR93S0;EH37~5`xM|T;22;A z^6Lbg19pF&!9D;u5A1V*`F&TWz=dEB0v1C0&cNkh4*<>vyDe}X*tY?TjOh894eSQ- zEr2b-9sz6*9LvWCZUgKKTnZcxoDQ4>yaPB5_&9I|@B!c~;9H?I_6weW`y2JA{SXJRU5>`)mj>5D3iut!ZafH@~%y}r< zWAQA6B2RB+C$puBb~4+k$oysDm`fjnP_O|ZP3NnNbD=LJwZUB3yzbGkf} zdXW4Sx#c-8bUuyzRkV|*X8g?j`yf9b91W{ZbUi3cKKnrDZ36KuF?E!K!axgw;KXYsQ&*{liC=}zV$lH^y zAM&ftWZjvp*YT^ZbRC+sH{U<$JZnq;;h7(YD3iZ@d1M}zJik+sqw#tz6IhL89wWiguDexo)YW^V|HYHd!ww$4{R@MPktPwm^CRrR#zGYBpV8C*uO& zUUa>q<$9&eufEgucWXR@&X3!49W6}WUUc0nQht5V^`(~j^Xor+2;ILPDQ`czP8KO2 z$Labazp75w*U9=LzuHcp>q?%y!rwR0bwYkso!H6x6JHu(G7gfz)Y0`VMLWITTI__Q z<^7MY1Cqbg(e+_|_W|9HpxA%tdJ(@%f!Im;it$PQE#m=Q&uggdX-3|wPWtq3by<*AL~EOf-9 zcW$~~+tS|j2~Ubl*8llkDr7$gIr^6VL-zylyH|*v^cQ~j3;j-rHSQz)_(AszNagR} zwD-`zitVEqu6Vq3eSZe71$A^CzNJ2N-GGcIbcAVPdIj;!r>7h)uMcJNgdgfC$IHhD z$_euKPrB~kQa&W(o{QGpj9lsWiO^LsHuUXIzSTo?H2GQ&R`QJ=8a@OPlINxJdGIR0 zFUr?xGyG>U72d$en&mIK7cIXlM#Jk`*@g1x&HRlwgpe{69j&*HVi5jN+lpJVW3=D) z(4YPHbEKFcNr4Bfn{GL9e-^7?_tqWqP#)i$4SI$CX`^T-Q6&oT@ynL)o73=?-&>P^ z;pnFX0rLD?$EUv$_qdfkU(WRTCb#7L^KZ&QA$2Qr`Qm=4@vjs$Tb`bVer?4Stym;a zOxwTZ`Ahuo(eV-Z->c)9|D8IX`QNeQnJCGVXL7!kuy553h&g@R1wT?R>7%2OL<82H zNg$dzW9@8CJ#ABodJ8u%B$_+DX$jGk^eM}T%BG!LK{RuL#ww!4r%tUV>h0yfmMAk; zZ9UQatA{rbmF*t0k*H~X%_gGKs~ffu%~ZADMl}2SlkFS}&SnxdEm^XIXs*(@ot%bs z-9>Q-A9I>{wBJb*p3ko2)HL-Ir{bBzPm%C| zuDdvuq%=`oJMk|PF8l2yr`a#HPZMVJA~+2YT;)`}*CdAvZ!?2avEhAACGRZGknr^A z6izdb6mp8&=PU`&^iShdn*W;9>_)qDBs{-tIj7>KWt>VrI-DorQsF92MP=_e&73>< z0trufyoyui^E*y6FFRf&;o0NUIn6v+#%XS@{Ut8``ad}pTNiVhdBrG~%iqhD(|`+6 zoTg~4=ag}}#Hq-uoKu-gyUQegrlA9;DfvO1rcYbOsp$O)PQ}AsP)<|6LgE)6vf?!V zqz|XE?Q=LyiP_C*w&@*C)BmdC6rabdB%SFB7pnOYoTk*Ka~dGX;j~!xic|4*m22E_ z{BFf*zR6h1naNbuGdX2^Z*VI9^E0QCz3sW*xD@ZT=hXYpDV(PJF630Ad4N;t?)#L@ zYB9h#f;la|vV>D6`5>pb|8bg`S;?uiy#Y7=W=9X= zH0AhYIv$tIzro4FXvQtLGv~#FXeX|P6K+5<}~wSJf~82J*Tp)98OJ} zo^dK>e^8C+@;6B@ndi)@G$oi*S=1sBEr#SUKd5zP6vWL{J^@h{j-j$q+Tm=QBe90IQ)rnm= z75mz7%6K_(n(s7{(@ZlzPNi+8aq9gniBrk`C7cFG*K?ZLXAh@%eBd;9{S{8L*#b^w zuU>O1o%xm10O1c#GgoOoAocOqHRe>h+>%ofGmukp6p!&W-i^&Y4MrUR7 zsFxk5Ki!YvdUI~2>G5voHI6$yYHQO!Lg$#%ga^Nuw_SJ7DIz+^aY)rerwOdxo{y8N zois0%3=Vg_=G1P_jexsL>z%gSbTN$$`r&l2cUeTRc#PW@xilYgLCo4JvWcJMY=$jzE>Y^tN_%an(nsPgTa9 zHD{@_Hn?17s>Z%c7DATR!+`ahGEG!FP?a4KG^av$T6@+3Kc?knv^t`#bPj^;VE7`3)zTPRu_lEHNoz85A$C&yL`d!(a zzI`W3I`m-&*oT-eyWf#b`Le82mVXzvtf*(br-vE4R;yc~X-0ci{f+V(H_cvbQ|R7< z<2}2u!K3Yr(zUv>b`}$xl62~vZkyzpr;QP@(-NZRj$K#mMBB%Z9lU1f=d5g1cK`F5 zj&+kntp4M1J-(NAU@w&&x%7ErTXq);f7WMNCwAaKhaWF<%vk02M*@GJugh-N`QSeL zPb;?5#0~ngk3Ctt*?nX79x!6Zsm7^~kF#NY4$W%pZ_y9E{twQM!mC&?>Mjay;NZrVC!OIPa;PSbwOYx^!3v3kLyi?lb}uv!Cx zjMBUJWK*Xk*{8+!V~_4VIC7ewBWp5a#fxd(WKLg<9r`}?7|eE99zUl%M4vUB`bWj* zmHpYNGiKIoTkXW=WG+u9v9b|wG?__Sa2_q#x<9|1G-u4&%mv`YtZMv%3$wO(XZg&V`O_f!~$N>^-LJ##+B_0Z*LRfwIkubdCwxlE2eWbx&j2 z+V!2Q+UeS{n{;+gUed*e-E(G;d)zuJw$zuMwNAyJwQaNgV~>hHtoryB6Klo}V%@Hd zuZz8G$5sbc>U8_tjNLib>9J|a0Cs%k(!hCR99W$#kD43D+q2J}TF$r860^mxhpf%l zt#H~q@uPOI$eeBWw4Y9rO%L{doyfR|{o=If?ug$#Lc6nF52v^%`KhoWF~=TR_vy-N z)eI^+d~G1xm@++RVFyb#(7ohl(CCj&wEy*G(`M}G?KyZLo9gtTcu#pR_GniF*^TU8 z>{P#(3ClW**|k3BKF69`v&UhyEEw(|pB}i(CAD9&OAjSTy1G{%_ z&%gWIS+PnV|Ji82uq*pcXu9>*9Y@yt;e{b8j6GX2EuhDhRlV8Qz5nX*dR8y?R?Pf7 zuNj7{EL`WUlUEn^&AQ~o+X3I4hL28p`1{mGr`XrijB5K2WFr@_Rie{@o`*f6wP_*=qw-dsXbI zaIzh2l_ql;#!eYtyKnQ?UTj%_$AWnydayRhuB9$*d$IyAU7@MPNY?NE69bzh57uq! zv*gL%L)fl&JwHC$KaxHFcutVh+@7rNf%El~kD0PBXWS1q_3O*JSsbW(x1cAx_Cs@Z z$VkNMOe&vq_Tn%$XYZ&V5nCMDgS-6~?3vb^E%>67eD#hiTkIvdHZr#-yR|TJ)`)ls z+uoq~n(~m2>{_>bS3<8#*t-LYob_(@XGb|kj$6Gu&dK$Ka~t*VrfhZ58+GYQ8+Pa) z%FAjyj$p^{|NeT+SC+jHTE?hH4u$c-lMN^xxOiub8!OwXRdF8*O`@i_sAP>Hq;o%#wZl=iuIWuGUe&KYrd6sz$* zxJeu_itY2Y=%Q+gGrQWPVt(&%Yj)1==V=vc4s31QWoz4d3CqvNxZ)klWH0wPyi=L% zeF>_(eOtOlf7lMk?c)YL8>vyQI=8Qxzmw~QZ^`$<3lCnY{ywK8nm%~=xx0Y_UHp$6 zHM{yUw#}%f5yuue&786P7SoCatyrlQw`s-7tyrZMtF~gbRxC6S+SK>8)Lpwu}?Evg@{Dy;j^_Tkp2}U$-6Ix4sTAoHKB2uU2-$l~0@A zJTJfRvGDSTd4YB(ELz!(TCs5}?%0Y=T5+erZ=wf0@agVxPI>d}di)mD$}VZeom;VU zD|Wcjl?cbJ*77;>Fwch#hW5u zF@nn@Tu&+G8%!x_Z`CS(msZ@`&Tr3yyZe8U6h4HTA8sV`y)yC{CKrF4p7*mSF;gy) zzr^9G#Zii$VdlC1?3-K|ro}gNi4^`cH4kGHJ(G*eQ}9jAH?Upqn`s8zUy=*13&MUN zef=+gy;_B&FwOIZ|Kt^p1>%x4ivAL2xhHd}yd&}P>v@Wv5is`P#aPMof67mukfI}# zu?#n%Zi-)|m`WH&?Cr4cd+@-4KBO!X;}KZoP!u}riw4xv{R%;gLCZm9pro?F7Q4A+M)55RyuusRf^j5aEINT5~Q0&IG-K}gi7aRfH3UgH^p@%AyhqW2|S@^X-_TJd% zV~)l;0Q(-;XJdB7D#E@TzfQ+~yrnARg}pQO{joR4z8&^IEL52f*gwX8aZgod9`@6* z55|5R_RiQ_V{e4L3ihSFRGH`4-^KnC_Q$Z_h5b70mtdcaJ#k~KRGCrO`(f{n{X*;$ zu%CeaQ0xz2zXAJ9?3ZGng1r>`vDiCf|J+)Yd4&C4>~COy3HwvnAH;qK_8YL@Y@^Do z#eO;Vi?N@FeIoXe*au=i0s94gRGB&0Ct@FieHiuu*iXcM4E7_i|3BdrySL#}{M(QN z@fjxm-#8LqqG0z{cukU!Jb1@n*Ag7S<;xqeDwZq7X)Nv-lyh|Z9{oTFgR$FEzL zP2!Wh_`DU%C;2NnJ`M?!;54T8+Du)mIEwMfdYwIv!#iL6`rp-o#36Z-IuoZ@4^obz zQ>;fhPLqZ0DXrot#wU5^D%cD0>tD44$&1fhv3!!hqT}O`une5WauBu?|8E>AU!q|5 z#jk%=KFN#ETd{mDe}%mHI3y^S96!!4MInEMsN73(9z#rXBVs{=_u^8D3#Amu1J z#d=5z@p$Q=%?PpocaFq&Q?UCgye4T#9=uaLev+@EQ!LjIr^&+hlvZ&R<8$X097Uw z`&jH}U_T4{#n`7~zZLrf*q_H9&y6t8u`k6w7aw#=e`Q9BAAFXrj9-sdW{Nej;$!e0 z%5Zhxf;(AyS3DkLm5OoRYIt#UkSb$}+h)wzA!AQ;Ts_aIy4Q%!b2@#W(W#=0_Jzs# z=bom81}DT%9yZK7Dlse~CVsM40)7ClPiWHQArW!dgCmZ=7+MSR6bxjA809le=OY*D*e3 zzOK$B4&(Y$*d!7p{3*ur1kbCY2AK~ z=Sw1b{~X5U=lfHb50_uSPx<+9@uWB`ze0_%!9Q%85Qv*&fHspWz-4kwS`oW}+Ds!p z{}9G)`^5KVjBQK28L?3b(IGf0xxdP3i92i7tdJnwgiJZYV+dB8xP)LccW16XvY)~x zarLQF4D+G!7>Pn!9~w`H!%WHYOA@WO}4G6#9fM3Cr5A%|zkb5J+oB!kAo~wgV2M*>msrmZVJx zo5gYCo+*w7%xwk>z++J{{sNgz5d6NXjEr%ZrI3kOhpEPS_>URl%$+rf4iiA~h>MR4 zHWPDST?@cr87*PlabWy!a{^T*Mzx=kSfDImlwBCc$N>L3+s-ypCEOXx@k0Yn$MzLa|i%IslkY0?Dg$83JuwdHCG>g^q)pAv&$^mV} zO5Wsna&UIta9^>!j9`l+}`bf+1|b`+6?*bl4T%%5raK`03)uS zH+SqQ2A)bv2N)NnV@k?SN=nOBl*^UelqcZ>KY-tg7^8k|8MAOLIG@J9%s&Tbd7R{b z8NYajy%4t*-h061pBqG8XLGOfO+uBG0+pCCN~X$1N=k03f;LL_s;0s-N~(jEC#l*f zSup|~RYu20lhLu&V06Z426RMJw)Wn&qktsfIG2;WO$FLuT0g-VR@+BlBg@ip&8!tg_IjjP8n!bd&~ z3EPARr~nd<%WBOeA9*@#|0!=qgJEz!3@(?!c`>*grgixY9>9qG7dE4=`1cnD>ltCO zYCb+5Z~j_O$0viUcgQv-z>{I#Vc$sVhac{1SiKe>&pIwlX%zmvGlbTg#8Kp*(vdpg zbPP_%w93;6e-_pkykT$+xIRwWQ--5=#SbFZtuAJSWhG3pGsBp>;CmEO7HQ9f4;-(; zVHtebwNi%gH2iLx4RvbGBoBES+?I;@8g)`-v<2-LHJM5-KC@ze%;NgQBON9mzwdbA zcVppD(hm6jy-b@CC5RZIPX{jiCbpRp8>56bBa2#v(X=gb@OzL#en$8+zJXu@qgJev zPkI$8I~`}k_Z9u$xBZRdVjQ-G4@(%&5O&3klq1gwB{YZ~d4r$L6PiFoHd+!1tRdw$DKYKD0a=(#4hUR{IqPfLbFE&l;m>sEFh}_{>jNu0_!V>u{v}qwBi!8+MBW8vxc#^XgqvxH ztM*A3F~+pRjWtL4y+F85H{Kz%HNi~UnsiBjE;*$4So6Wy>6V?0<>`AvhOdwK8snsI zy;~A1JmP+p>m!n7#37dqX^NBVM&=y#9F&oao=vWL;0#NGeI(ok|i_C z>^)lggyOAstu#rBuQa3%UnCsnS+??4&$~DUU@tlCg{W0TLEic^GZh zwwCgTTZwgR)(z%d_fzIT2E!38&rY+^FH!o1J<>1emVTvi(yz!R{W|oXCQ9b-V{?qS zQKp7aCUPhf9?C?=W4w2BkabHtZcekohvyUCHhZnYJU-IgkMfj(-z3|w?`I;9fK#?# zr`daPri^M#ki7at8C*esTQrpMNk7Tz%IHXIOKC|8B?b~G%vxrOHP2YT%x~%=-Xk7~ zKg7K8=hUMQMMf;N((E*weeh=qUGXhe4`jBt!F2IYG2~UypnirVndLv1_+}B`RMP69 z{nhL5fZev9ZHf}P04auV~R|vPnC-*(qw=!RMNXr*+Q6T))h~o+ixVq zNy6>!F6|GkF+rMi-6Jw+Cu_!wh))J}qccG~!Bo?34)#8^*Lz%=B5DqSBDEtwV$G_j z+mE)X=#p?64H4xA|50I?{rCCn&TB?ag5=;Q2R}K~`JCF!jyx^Xv2citg7CdihAHuH|q6I%B*r1#aAAijzO#sG}{I{VW14V1#0m=b}E#a3FK zW-}iTkdJsa`>oPU2{1U^3D&B6voFgq&Bw5-P&>`rV3K&+6GiPl2HVwM^Ok&4vyXeR z+YT?*9OZ{NYgpa7wVl?yMz;`#5FaVZh>nxqkAHZbeCx%Z(Z`-I1xRauxAaG^r!`3$ z!Xht&`AbMc9(9O3&pneM>79w>U#5BOPh2n6>%>>xo;Rmh_XzibdqH0$>?plLob(!D zM%y!Ha)hsAt^NjWSSUS^=1(>I;`w@C2gEZT&0?(8SKRcQUG3B6m;6r)Hq39 zpBU1%Dla#M`(Q|Rr%N*0<0Pe(_AyAGOJCe}FOGMUe!S)h_dqA-xD(t7?gV$z!yRKf zhd+aZG5VEcg)##f)|{GzdGq3^TN&=iydmHt9_G9u&7Dw`?QU3Z$n&7;D#A6#x#?~- zrH7HKf~4i(Yq{qx=wer(Q3Ik|Z1B#ogvI$8m)e4|BfKI+nbiBpn+JSs65>Xjvq@ zai;k#?B=T-`t-Wf9OY+dybL{)PuUzp8&M$hH*)Vlv+0*H2H{9>9QhI*me)uBXiSvI z9KzonuZxJ+6v04&Va4^4wc$)K^@zjqA%()8XGyKj97?PmZ zNJF$)+aLzaJ;KrYQ$YP0PyJy~t!~g2>&`onv8*v&Tmiek(YP<6+VOIly<_^&$7f65uFQ_~P+B0BdZA?| zpZMo3&yeL!@v^Y7k6c-wAeUAoO2IM4T}RxKa!A{PLDI3zki-&$Igyp-DDxNaA6P-$ zK*Yb(?43wE7R-~Or9K(Tm})3%Sh-#OIYdh!Ug2bWZB(h$Q$6ua^kP|xOJRq-t`dk0JTHEUU!z!n{)aPa5(VIB19IG;5xv8~v@c zv|01)rbs$kvnBA7AuZsv`c=JQ`pRHG@^P?f{|VRJE3CArJEu9yuZ|(mC-@iq3;vzg zy~F#k=8-SVHaeJXFnh7mc(k~ z@@MAhDB>}icw7{v(^|(H*y}o;)(^eEMsAYi20^K=b<*!o%I?bQ$Y4J2k)+>8mxqAn zBZpI^o?%Ijbnm{?5Kkj*;7TjMoM!K^1j#VGlGtIdZQAYl5!In-)xN1cPXf0Z^6_ot zfvqFjOnZYniE(GnV{g}aCSzk`w3Wy9`l+TXBa+9hqr_M2lF>6=azgc+WqG7&z6rZC zc9?qIX^!%v{Zso$?sc>-D{rTew-b6@!`!}#aSrjwzXM?>a3wXrMi=j%=yr|lPuzXX z_ipbw-_!UPl1J7UDoe8W^c+K-K;2B-CSNW2st>LX*D7^^mN-|71^==&2oxgXcMgQ(&uCKcgt@bi(uSIcS zQQuNf)JOVenKQkBH7;O3&d&@fev7u*j=R%rEbJ!>&&J8@Q*OERghwVGV@`Xd5A*FL z>Bky%mN8Jw)p^!ebX_ZNN|H=Dlpy1q5}79_vv$e2t969<-!|mpc0+R4SosreCJ*o< z_!0aFek8_^cL;NZx0Ab^bw$U@E$G{z>3$u-b(A#%_6=1qE_%+>rB7}0IOr`i$bDmu z(RPhXvevrfn8rctXg%}8&Lq=r8ulmbxT%}-X5xiA!JVK-=N(acwCwk4&p6l-o_9>5 z^sn&K?jDrbIb{G9W;rT-Mw(j*9J;*g7sHY$0qR zY$0rkiJ$eHLZ3V3Q7(Ib?i9EtO`g&FQ;y(zf{dhGCVM3Lc6)`1ikz37AlYZ=lREoI zQkeCG(sXGKxa6;(3U3eT)way{mnk*s6p zf#Pm)`SY^fq?>-Vv%iE+FjfZ@Yl)M#!9_z%v+*wNzS>PokZBc3;%m>9zF{wY-~iEU z6TwcsuK5~cR(;+6g>@DD3*iaj33>!Qf*v8PsPHxZ5qCYVQCRJ3PCr(&OqH#~@V@&&zrF=P%XzBZD!VW#<;*ezB&_U(2kV3Pb6Me{O1TI#&5>|OfltUHuqt^_)i*gACT+p z_=jhi<}U0~)b5#FdFE_CIdH1K+t|_ER|DsWhHGB3oy53#5E=?+?@vj)T6Z-Auu)cmz#mOVt?i*cl zv-=!fxMHMB_PAL`H?*z&jM~`d3go4+cHfySp4Jp;E2F<$?~=8s$ zP5YT#tI%uYkGeZ=rW~U~&|$7I<45olr4K(&vl$0turOK+<)tvMDe{S%e#Sq_B@coQ z4d;?1xrB0fQTsp{(K?9xKb-QIXI}pRH>d5msQdF~tyjcD`z-tk{se!5KOu}L{n}Tk zyB_uu<<7`p?~5@;$AxXI7y0wtH2p=7W|F+9w4r2S9FnS-izHnwPT;upD1^t44LBF71&@bp0^b7ih_zCe7!V$s| z;_TFA)#(iC^f>DDupZ+_t<&xecAZW|$o*@aEE(*c=#qoc|Bk!A=91?q1=jPd^;TX{ zQp8+?4<}M*m~Sw~%$bzg;Z0?&w218{wUlJWhZ;^5>Zfkw-*%wS#@q=RWVLGtMwii` z+W{H3n)_Y+-u_9_pJ#=-hTOl_T)AjVZAlKPo);o|UO{M6&nS7rQlk3`<9`q*x4XR9 z64$|fB<=S}U9saXZnc=t33%4&p$#dncFFVLBxQA zmz~KFZ)YZD*eijMOD51lP7PZ6qs=57w}M-Vack{^>D%kGz7}-dLtF!|Be?#|oXNwO ze1tHg?k>w>kj@0wb+x{Qhf86VOKQL|jfajkSVL3u>!8DqTdX#Z(;RzmN|Q}IzrC9E$a&=5742yDX=9g=JXgkXM2)EXA zYs|P6+)59()L(~RtMAUyzB`n6dz#!{(ML9umshuE%2lmdGAr0urj|0kD;g+mb<6>n z6CLdilQE>`J8lKHf?L6@^l(d2>S0G8v)&A;oe$LiP5R|-w}otk7+vo4d~ji z6^|KGxYZ?xz;O*{#{k)JHdAgol_l4o=qqK%vSsy=9J%UHKiaSUa(QE}6xDkrzk)qZ ztT$$}-k898qx$~3*y3kCnEaE*VI}-l;kUxtY(gP97d(+O5a_jN0?e zY*gnc8PGnOa*!`--vtsXxwaxpR-8(b#V3+w*0B`1^hl~qJj6c5rgRzFn87{{+CRn? z>i&gYF7c@!@*&n7rIYI#r|$4EN5xb7N5T}s6v7n36v7n36v7n3G{-ZatIxIQBeGT)8WqvOC{?Ma9uJaeA^s=1Sll^D{fV}J5P_XY$WcgZ(F=?%n7=fTuT zU5in>*FaG1Q5S92cxn2%@95Dz0#SO*c=cva`{)eMTobbLr{iIl4A|E*f7EUUIBmxx z+T1gLqVzcOC)%EV%Nd?CKOP}JK#%;u?llmuvhyR#+)I9Ny<3knKcei(4~N;yb9!@6 z3?)+AFTa*Y-GA1}exFwM`|zAF^eva{2j}c~IL+RneVDIg=^C@{8tN|B^{U5Q$EPkH za>*@V`exFhYulZS0fJuB?vL27R(ss58>czSpN>25Ct>vA*Llrc=l;yGKFHGqT_5Z~ zZykCAA0oI0Q>Zmgjg9^=II33BonNZS7Yx_nXad$3N(HPj`)H z9~Ap5lHWeR2SUr*YP}!2SBG(*hqBi7x=Rv&Lj6aL*+)c~srx!UjB#f@Kb*+(!$O`P zUc~dm{HW)LnzmEun@k)7A8Y&`amyWUm-pfn*8T>uc6nK^=^7MD6CT^~fky<_76K<9 zMzSB5b%c~qvRPN!)J~sYR^&21na=oRQjhV8hF^h-I@Prq!ToN$ywS(y9ZQ}VS6J)k zEuKL4+!J?syr(+$%w;^^oz6V}a%p(cB@4e#JAn5*!i+XkFK{ckl^D0qvT+K(fsgs; z+7VSY=y&>t9{6{9~86>&OF-j!BbE^QYM5svU0iy3-uxNBbhX-*(!Yp#CC!9%;AvzXf>=SiOa1&!^Xc^S~oVwnXm&AH_stjNr+qXSM zlF{d8Z)fu-F8LSWSN(eLxu$KKwFi=Ng8OJU#M$A;JJ`cs{cK@`6>=9k#*(}@!{^#$ zaA&&oYtP`>eU`XO`%3dEm;48K!3+;wQD(x&z2IK(tNXmKDK3_j;1b%)WfDk^lW&3DTdcC_ zG<&C}vyV5I=aXLMf&*A%x=i}Bho*ngG}>?W2N5ANUbMsBa<7!09W0Aa4UyR=hVpFK zCq+l{q~Opn8QnBohBc0m!Sy4>TQQ2~?0(^Ca`(DR3;wO~&r0!m<7D6v_B?JSKJ2j` zbY`h!cV0yswZgRj4%b%eHS$8;In7SL-ZlMY(b)`{bs|eHJJwewAIgz&P5nquf6|l7 zzGN?b$`oNFq))cH`;;#9=ILI%Q<;)@B%6K3aq__QIOg+&sq@0lBJ%HI+QBPK`?i^J z(rL#>-8s!pzt-AmgRl!N6xqK~Ok!e>OQodg zGFi|#U5e{3m#Gz3Na2}VCBOYP@wMJbS^X+;xI-F-#mP^G$H~;2tUPg=jr)emeP>;) zy~fFHJXbG2=8T`ImKAq?4g*;au$#eC*qG6K9v%mpexgGu5vRllZh!jX` zZpI#8_AjPSN)B1;m%5LbwM*>G^~)UI0eIUQ69>pY?@0FFgefmYUdl;7Nk)&Bll@$? z_9pTCQuik5bK82C=pNT3_9N@QdgI=qa<3!KTmDz#JkBHI&^rXZ?%N4-QGzVOe1#c~ zy?=jGf@}&7rQUc&pLOW2nn)b;?ueH=&h(Sb?d<(&&6Vam;$-|+z$e^qbnX|tToRVYFuGN*JvwG+L$&PSEk676|8p`Wk_~Ov@0VkL9$pY&MNKG zy?;Kh%q?X_?BNgd-Hn&yq!om=Y22vz*gNfvY{nKvEcL|b^%f+`fQoE%PvD*L0`g(3 zB-{1b7paf+eWmL+akAmJq}|ldT7<)FzE47#adFc%@6373uy`59I-Ip;mm?jt@88T| z%zQKP8?1jYIi$s-Uoet#dy{KZk%ift5vtY>qc9gP;{wj7ylmjU+phS zkc-X~$*|6gC8vD~W%m-%Yeis(9j92c>g@KvC`A_0R$fB8H=Ooz0PSTa?PUV(<^3n) zi%kCT8Mr&x28 zA9Ku@OW)0W7@gMs;b^_y+4K{Q4^yu8QLY}LT+J~?1!o(hie?$3jOj*qFCJ;nwC>bX2?TS?Py$+Men%I8LGu88q3<)v4q4M)vvCr#=12A1YJ>n2*YXiPEMDR ztQ+RBRynw+PzD>Ly4xn_yFH@i+N@(!sAE0%wrSZ9s2lSgVIT8L+Ju;Qvek{23u~`> zw)UR^EidL;SeWMmjFsBnh?ALb#!3CHq>t|jjX#qigLzJoz;g<QPQTu1 z={$=Z&s=wc_=+mUXI$57x>{%#G|mIroGe3vyi>)# z&LKsN?I)R|gck3nL9*s-iYz;oDhp1e$&6#^GVMqPYucGIrYTECH1?ID^_1_59Pa=4 z^ZVZz`RRRa);u!b+hy-@Ymu~k5+|4bg?B3&?0j;Vy_*Ng+OzpmdTI>o=VN8|v2il} zNP!d`8ZQM+6J&JbMKY|ukUoDR`?t&WezYaDSWl`oEFLLZ@7Z()S+lQ^j=#sr9n6#K z?Xa9?v$kWr}o)NuUd6T^TiB%jGH!t{ba4leAC4(&wv)yuk$;?R`cAsZfTF$ zg;Pv(0K3y_r|of3Us3a|KzO!j`fX1#&7WbHt#-s$uRG0Aez>mTbnC?&6*lIm_~~4k zcuP#Yt@dOh?a8Pf?TN0b949VgsYCg{MCb%=^Sz<>BVR#CTH|(w-FM`=CP))GDCYj< z1Li2P`~9~0roFxonl4Gt#fLO6S7V1-$<+7k%xC=P$H=nTt#?Dt@*SN`OWd+=674O& zH<5qoFq`xINiKh;xrW0#(IxRA^{b0&=}X-*0X%Q}btBIN$8GZ>>1Oj`(+=N7;U7z;AxPW2W`o@Z4AU-k9!3NZgSoUO-}8naY_B z`dy-at^}9)PVc06zpgnIaH(<%|E7YFit)>JWgg+3l+$6l zaU0@hN}Z|CAA`)DiBB=WAu zdf2>I?8(yki>_tYTJO!8dwGhIB()^opQz~?c0O)(SG(o!;9m((*~}Z-19N;yzGRax z+2l+1qzvX<9@?Ilm^UAhc--hu^Fe>gMh^Q)cn>%yNL$5wz&S-O(cCopvG=$%Mfx$f z>Nm-J`_>$MPe2@Ql@n79Nxhv&+xPj#>v{hGthzYfWUh$Ih5LRI?+O?pHAnKx zWyFaKd&?Gk9pAH7lTLc{8MmSD4kO!rT=z`aa+ioZ_E(qijFNrULrS7%%Vyei{q@#| zcy9BsTf&#HKMkIR`4aeU%=)WEu6LNdy2t+Q?&sqnT4z(23%7IjTHgsvoDy$-FHFt9 z2NN*s&pR%eH5K|N!zXE7DII377h*l5kauR8i>Z#*N8B>}QMWux_{t~u5hwUHJ3b$% z9^LoOo(#L+v^3c5&UJjFOutk10%0nvdE^6b`M{3D6Ufhi$n}Uha@{Ih)Md)ZAnL=Q zN$1N}W;|;LtZPQ@<;JhO3-jbZIz8#ru>QB>{$J}lu zj~?_s?}oPm<%sz|^FM5>Th`oUxxWwI29(vma-!L$N3VNC;`}Dpi|Z}7E8&0S_8xV& z&2rmc*-RJpDM)=PO(kulaS!!sU=s6!eloB$hj}AosL$+oY0Pi^jO?*)t^H31k@b$% z?;T9+^?Hcw#(*UHfNUA?T)!?_%(je{v`}gw#c$;48qn}w*MKhceIX4~zh$LA>mGA| zOLd=n^gY(=-U)mQAlS-$;Wg$9KN7yY6pE}1b+DbuYBN}?V2qhf9b>&BWYsz9TM_F| zy7rXHb9u%S>mM=(CNLIPJI1lJ)x7^7 zGIWd?&N{z;@a+u0F)T@j(LWDE=P+~*BTd654e9WXW6j8TNY_j|?6-kfHM8Q@!T`lw ztD=r)p*yR@yu$(OzS1w0>N*tnmiy`*pCUtfx#!$_%GqGb+29iEo_q6&TW~aGdjxT$Ep(KxF4d>`MjN5)Kgq2B z^kMzS+&@ejGbmpflTt#4x!*ZOQfd>dexNf)hJ>@3`}Czx$dVzSW&fjc*JAazY4o?L z!n@r1wF|Sqwdw|aD)rkjo-yCrG<5GKWvYK|^vu^_4J?E7Wz?nw9CFA=G9+mx=}9Lo z+!w7Ek#SmZc)ZzOC9UwtAfQ!K*=%Dcoo{2eVz(3g!EXP(>9oNmuEH+nI#czz3cnw% z@tEIX^$y{EZrY^GJxPAO9^kruA5-_T>+cV%sl5CWwH-%Cq)q(BDvx{u`b~|OX}HOB z#5dw*4cFI$b9Ow;dqh}$OU}=xGwJKK9&6amgl)B#^a0d+FZJF_y{9ke$n0xKvhD5! z?sk`1@#-yHZ@?qNK_py_zvh*_pSI9QBc zg6l@*Ki0DVH>(_aj6QrPk1~*%&G?XZC(>R<@^y5LN7jK^g+)3?7&Ic{zP;9W{=CL3z0lndZ$NLfNRMEWvee?A0z2D$9d*>H;X!%Nu6Ze zd%g_{nPrS;eBN+WpWJ2W{DJl;7ah5^)_lXrV*gJ#oi;B`vYt!!$JVv?_Iu=0a98AB z45EJw_h)X?kFawHJDdJG%Nnnnx-!v~Az5!H`>nQtws}uX+hC3v$XD7yFYTZ?9;(f; z+vkeNJ_-9R*7JRbcU&Lt?&mVQU6g&ZY)~f9mrc8)<9P2#oj+vKo@GeC z+VkyMWZl4P=KcBpInw^a9X@buaiyos*KtseQatv^%mqu#^ktUfSO zCM$50@j7iG-^SISHOII1X++#y`=&=~zy#Zkk>--LNlAr9F5?RNVsm^kDLddDpL+37 z>g7P{Wq;~r-^ppub+0^=w|b8t`}qay6!d!@^2$hHt!9tA(EJ`HbvK)OYrS>gcpj_% zbiS(P!W?Jy+UK@d|b*Jy#y=W*0Av(6j~oH>BI!2U zw)1mfYfUAIu?uru+Wef7%&v&vA9wNHs87$=F>~x>+%54Pthl#Wc|mt7dHG3_f94L} zRrxC8HJ+iCHnMJYm+b${BYy)2_gZ5D+JLyJc*X zx+3bnEbn<=%ylANLs%av=T<)+Ne^#dac(!?Q|`_S+W6=_1Ln9Za}eX~R_5zrd!K9(Mj3j#uegHrEj&iMI z6lw3LEL3;;P8at$gL|A&k`l@^$3d8pBk5}IA208NuSfDn*I{Nd1~J<(dz?ARY%^OT zer{zi!JR<+17(9f56SeYeO@I@y%)r*w{~C!-%NWLMD9hR*9_Os=L|<;!pY#ee(OKh zUCXN%j*m3;49|sv3&lmhVIQl%d66|vG%wiyBIynEqF>Y7fPF5wPY1=oUFa1VG8d>k0)7QP16hv|2e24i2eyKH!L#5k@FDm+=zkzhCW1wv7VHB*0KW&G$J1mKm{9gLBZR>~@#{j3qkp#>9yRaLT~CbVr` zS#53EHVM{ms@zypySXg9Zhd9AZe6IncHM@Ws+!tfuY@Y=%d4DMHq>kmZ3&mJ3sr_U zRP=VCZbMmhRb_Q~o%5o8Eq+~iTd3S=Sy5KEuB<9lQRcL)tXsE!Q&e2A(Lig<>*~sb zF;0W!p>V~zx^Q{*hRUdD;weW(Ib(PhdswgsKak+gpSRT2jw#CM(%Fw#nns8Y-;$%~Kb$Knub( zweHn@cvD$#?$z3MU$=qOZK#OJ_+D-8{O@rs<~GD!x9&!7mOXAtw4*SML6nW-ZbjKU zZ(7ua*qaosF7~S4!Wi%2b!NUqTUS?R>FMc5{=i?L;CA&SP^jEE`365Nrp+}}@(n$TF}Ag}G`Q=m{x3T7 zdbS=>(W`)_p zabxL+LK-~3s83vaH@>DYDciw>)p5if>-~M@9taQt8Ygm+3R(^ z?PlA3!K=N5t-}Z^n08lAZ#q!u?l&sSgLcmmb+n~&T4dz=4kbH+24{t-48+kBnPwKjj%<^wh#wfSwEPuc7`Z0XCjxxnTt zY%Z}mU~|~!uiE^m&EK~9M>hY;=1**nxASd+&6nCd-{zGz2W+mkxxwZ=Hos`|ahp%r z+-Y-{&HrI@%D1ib^|N`D%|$jB+w8abI-9Sz`Bs~E*}UK8r)_@G=2vZQxA_k?pR+m3 zE*}Lp&$W5A&DYy}v(39~HVYH~672hb$}X#IHn-TUXG}SNf42SqK1PpQ)@{|6YF$~l zW-~RnEErr@IhCUdBla7sw$xP!a~<2Aws&<51aVtl9c1>?)4r~9bBK0QJAP(H=da%& zD`#D`#6Pj9aKh}xi}{ASyQI7ONEoXeHhDowZW>wYHmD}pVu6gC*#btHjIkmM6 zrev*}FA%osy(w-1Nx7UAvnWoL^!dxn&08t*tGLDGOi_B+4>V>o0txrHaEl`kVm!^L4lWN> zR-1XwhfGVW>Sk0CPj-5$O!8NjRWU;RshWtaWqLzRb8E}Xf@-ozu?T7A`NX zS;gRWRs|~qW8x!XX-)TaS)}32soq*yTT{K63TS0Q9l3DSyqCRQ`l`6{B}?Zlo;=B1wBl_fA{JU%R$EzC9iGLpo3y0kWLAyU?k=dNlh9_8 zFJYBOyf{0g|11Bu&H;TEp+8Ne@;p1WK3Jb%%wAnO(YA`Bg|^8D+wLYuIj{}9#bW+^ z{I}g*Y}rKrxAeJK=k0bkDcXf)vh+o{E4r{do&|7G!<{i{cCX>a#;<#PMg?daV#BSUSO105F>iIr7t%McWc3$vSF(EE7jieV zWZvrD(m``){(St``!;h{(F)Td=HD{g?1OE0%VV6F#)##4cNh1nuhjPJa2N4vU$H1Q zeltoJ^jbe6o+J8j*K2w1;cn9EHBkZDhIKYa^v#?#MPFIz@mp`(_+TqvE@1@U^Vf^J zo{q4YZQJ@_)mJjF_i{QX#@##|+E!6SM`-?h{GVZoW*w#ViTtVU=aItU!%z(L=QICD zx#Rhe_0wxkeO7~}*DOihxquH81O53xj60q%SwFp2Y>AdT%c1#St#g6qhY!pE`tyO$ z^?~;}z9%7FSMsk#-RaKwGVg2=Rhn>dHAi+M^sjmaGUejc2JT-oX z%W_w&cKq~Zp$^dP4*7DRKcD#@5B$^}Dx!WapyLP^1$2ko8HU{!DkK zWdB2XZ|-`8i#uohif!*b++pvGU&P&i7C&eHMWq)*;CBCX+{(SsbcQ=}*p8v+! zRz6tW)!OD*(md6l52(93(AzJPXFe{fyKrxguycl6Y`gKnk+G0%WtQ1QZoFR0vn?ZW ziMUbLvd!z`raK?3KP@k@KaF<-@PQdXf2KZ~j4ottIaE-0w*wz22Kwva4m)*c7?!(8 zxz}(_J+S^%?+vD8xzl`EvtmuJ{gJxU>pq)TtXbJ>e>9ov`tzCpt(0o~%<}9g)I-hd z(eBjGd3TmH@ddz30qectNAyt z44I*vkmtTUFX8s-%4#OF;*yd$Uy%1gA~3M))2ENb$MgMU8!qgUllf+&n;i=_;%z&( zhxa#aBt`7poEK#y4ZFm|#BTi>P8N0v2?-L996>+Z&J#~uYz&Clc{t*=t2917w)5@ z*TXdF!0*OCIIf5F7qwZ6AEwtxQlmL|&!Rf$;ZuRFg?W29Zs>x#%PVjnT{C9Cs3 zku6=3t3BJ7w%?>SICVO8JLU5ldY4YUm1gSc{VIDm|3AJS8!!JadylWb!^)4g_brxx zvN=}4e`0RWwXD|5Y9T``y#xT;OMg z?YrRbe}CNm-w2^>{f1!q#!VHK*WXaJxwV{#~F@U~MJ(g2lr3-!fDF4=*N$%iY~^>is#$KqYYd zw%haUoAs^VS;AvaSp4O0cAQhG_|^uK58?im$KQM0XI<{`|I7bjSryy-y)Wo~hVA}E z-2ePQ=Yaw8;ek^JuDP)OTOTuhEc1vgd+)mUe6sGn>)-1+Wd8CyCw|-USLT(sS}{9i z_iMQyS^ducQ@+jqowoH3vi1H~c>*a_{09S;9*O=x*n+Y9Km563esT6MlH}O_So24J zhaQR^cTBi)#>(=@1IyyNaIm=8RMDHe_3RHbw+XMSF28AAReANMa7Dz+1>NgI#fT#` z_vWsuW|Kb6*N5TVE_-!nh8XRzH+LG(jrQa3bv!<;>ekwO`07434|>z9duG{Hu(8I@ zqex!=U;h7L4$#XMfP~^S^Zk;pOx78Yl>=F*FCNZ*6=XkL%A&&58N5>gFUu!RxDUWzr6Jsl{ciYO5S*K4a?(Ytl^`oG z1qt(bhXcM2j6hbd14YQnPhCt}kd>bYY9E-!dpw|UR+`BRE@f8?vhqXI$x~$IDpn)H z$R;E2L=M9{z&>Q!1l)`TAMhKV;><@Wh}MS^0Bt0$DkohfJrCmDhr^$N~5d;2dEoyOvUB zuvh*XC_q;J2AGYk`~oONRvxyD{6|(U0BTQIm$*iTARQm2<%W3U}r*>?~31zC9)IE1X+ z1dbvr?+3?`m7BpyWaXcL)5yxd1sTLs`D0Ls+yxKX$t!Q9U3m`hAuESK5wi04z+z__f`6L(P;xW5^22S`{zIfO^tOIXOtSAglr%0B{&k(H0x z_HD52CN1Qf@_KLRX!6XZX#@{pg=4j}vBN5Cm$IR-O#gJ3Te)v_;fUNuu*o~}w%O#vYkF0z< zIEJiz|5QUxAa_hRr05Dm0^~#Sm4?)UHrfH@_*sT@A}jZqZOB>V-S7)@c&C}Px4?g# zXGjKe7rbx2A^UN&AHH+}XJjH5!@mVbkvrf^7aFpf{4a*@0*xhUCO>}_{*jfBgEr(g z_nt}J@V($TaZ^48IyC<9XW%Td@~i;m zfxJ>qE;IP1BJYyIeZf>@<>6pHvhp;bewBZ4h|tJkH6E9Bgo3TfZFed zJ&mLhS^4gr!~t2k1q6|m4}60>SwYQQq|s^#xgZ$)kpxMph01 zJu6fB5XeAohW`k(oOi(mPf|~5e+uEbK<)kTW8fg}mHp2VCuHTF;3V>H_&wl5R?c~z zG$Si-0)AxW8c>0(ybIJLE01}Bx{s_p8K`|R{1OP0Ugg8zBR`RqfB1dkgRJ}-IEt+N zJ8&FXdD%pcGlZcW@49+0bt!Ob06KcNq@Yax*-()h<8q0ib2fdG5ROOnB#c z@p_JY$1&QRtEnUKSAL2aS@|B&g{=G>kZU++2F^K7oRO7B1GO)NThyMoHUG?z;Xiu#7E{C!Y`to$Re9k~TAKS95M z>^!U7c`my1%yd0V{oj9$`?YB%yG{~UWaYu24O#gz(1ENx6Qo?r7#m&y@{#>;Ca@h@`G;U9vhr!L4_Wz7YDRXRY3@7=-FZ&Bo}WJMBXklU z<;kE4S$P#Wgsl8?&aFR!to$zMM0TEKKk^gmJ~+v}Q$F5Bn}DqR8OSCdlv6*YA3;{m z28)q>@W1^#_i-Kd9gdt`?L2qdc_y`pbJ^~Eg)sI$?z}1 z2xR4xU^=q$zX3n8^BiR7dCPk4avkSR7p|udh3^3&WaTG71G4h3z;0ya-`Mufv!k8o zQ0sZrKN;eZM#5G82pmRMzIYU8&LS(%0jH6jXM;P>6xXxGe=v^kI+9-Hc94&({27>v zto&CHL{?5LaLIP$4ETy^Wbz!_xa zCqV*kl)nQ8AuInB6d)^qI*WWpRvtFjvM+?^F5xUY?EUa--i>y2bB+#BHPX)Qi%JbBWtb8@_BM0D%uEQ_kDt`{#gr)3X zhkI=|;2Q#*-Hoig4Xj00ejMyZZifE~s))1lfim(FS@~J84_WyrIE<`(+_ra~Gw(d7 zUeBw44>VBzl|Kdhkd-iNGQ0@U7l=Cz)M-405zSMCBKWaU>mSGfUM`8ZH} z=UK~ohVx&yP?m^~asp=(pFvjkDK?Uyu%07agzP*|*m-902VC!W8)4%{c^oL(M4JiE z1hbKqOTkWL=Q+#HbDBL3F1i19msFrf`5DlNto&opgsl7va1_~jezl%~{p=3HApXif z01e2>$G~CaHu$GIxu?Xzd3LSyj9lmWxz4k7^~~KV_fcmnnd`wzK?qs-I?#Zuyb

C0q2mFUjiepXS@Wz0*aB9j{)`TJU>~_Ri52nk;6`}^Xhl{& z!MUvM$jBKttMbc_ zQm$~L{5Cj{Vv?D9aH|aZ&m4CaR^pNMy^MUo;;J^XyDfY@=0!_%ub06pY zZ)D}Gf!aII#dV&wt7q^wAEXVuf&7GD2YZo~+rdF(09B?K>@P!JXq&BvU;BE zn5SslaicsD>_Apt3icu^hn^-~$jUbZwQqpOJ?oMdWaqiB&NE^4Y}ggwrfgKxmcr#A z7g_lhkdLffz*(OB8d&xI{-CeEq{-U1qsmA?jdBP&n;E_DrAc`i_U zKfDJVMcxnp6|^JEbKK7tX(ww)7aTcHRC(zS@f#u^;LYGLvT_|birfG6vhsvumVF_7?Q#5QxNi~v zHsXLAJ!W(Q(0NTE{M)zjdn4r^E_{bNjCpyq<=^Yj)RaRNiF&T$Sk8a+6CdR{V4ubf zj+_bUJo8b{e!OCFoSZ|Cay7`sz49+XK5_@#XC-N?r>? zNIU#0Fzw-Au8xyz>^tBO*I05F+Z!|zna z$zs(5OH~}_7jVDe5urFK-A1_ZOIzaP_|4QKcn{|n72Zl);W)=g&pdkaD{TJ#GV>sK z5a%TNkd|XDd*}IW&a>V0%(okwIMZ%B z^Az|#(1fgf7#u=Y{ysQ@to){J?>v)F&*}?5LLPpJwI=v5$VYC5&wht|AidHYCt1(O zNda;5!4JJmc|)i3j72?vvHHiv2RF)%uTV~qooA-$`D>B0)|B&)#YxL8d>;~i3Y!ff?NKXyhW$-rW4$E z+Q|?+``tKey$Ft+E$BQC(0Nv%o+0=-*hl^-r~jI8k(Eb**&^^D4lEVoQ2 zT;*{ffUGB5@$mpD{q>N9%LzUOV%Zv2~Jo(xNs`r zV&4p}pGJCD6Bc}OhFcEaj$b%(9+~s3F+GDUk8{DQsN>3Gz)oc4%fUWm<=Nmcvh&<7 z=b2v4^S%Oe&z~RWJSR-g3%heZXN?da<$J+&WaYhJF|zW5U@fxp_w(o-bFW}s!V)6w39!U8LM!z*%Vav+j0op(6w{B0{ z_R1-o^`&bO$`{$PAFc*I^oQUUpl!SIua>xF1okK3)k_HjS^2K3D3i!L;WvQx7wz!B zfYaFPx4u1oOHTs40ZiY)91#8h%tr2n)0bKCFNALe0qh&#E>MB2d`$^?io6zn85~7! zfzJU=yL&llEhSH|SMCIAul(W)(tHQsDu#awG+oExij}4TFqHWK>bdIZ?xqwe8`rSU$*5I_>?Ux|J{~l4e3*$Q+YQ~oyrGoS^2lN+yURf zIb52SD%gDubr8SG`9STJm)UXZ`)Uk}tx6}%Uye&s{9tbD|lm49K&?Qo|pE60T_zixN| zP@Rk6a@)QF9(|*AU-ID^pkamJJ+{0T{-G@&g+BzU=L~#Nt(6bO@V9{4AA-M8XO)2_ z`2H=N71qdofgikycq8wF*Va>Kkd+5-BhT;RUc;Y)7UVPV>o;5SG5G%5+|q!36a3%~ zx9msW2PfW1z9FZ;ayMlLS^4sNtahUqzGtV~e5Sb*er*?J8aJ)*>aSC`kpu8Ez)yS* z!xQ#cY0+<5`+=q_1Yi9Px0K#VoZ-U{5H51@gXje6rWHN|v}`Ef`Y`smX@Kv1lsb&u z2oL$DRbPB?>VD1vB-{+R31~g?KZctF^na8MAKV7cYB_nFGv~k&wTJU;xe%Uc%T@4m zK;!cQ{I9lr8oulax8&Z<+#LQgP&Yf^H*NViob@fsJ{v9vW;uu70NQS6JV~AaEhox1 z+j0Z^En9Ae|62f-H80U#VIPD)2iuY5hi>^fSdH8Ue*(;B z*6`4ymh6K!0Znf+{1>44b`~D+vLz3Krvr7Pyw;YF!lgg9;-);i#cBr@!#@D3=P3No zK>eP8SG{88`D!@$D(Bp4oZ%mWgUEr`+%oEI`Y_~txc@s=8gt>DAaD=uDm=EGvW;8- zC%#MFK~90o-=l6KSHM@DwCczSc;0WV^v;Jzby#vf{2I{v-U|QSwwL$aG7V^6O^4s^ zq<>h~ba+XDN7|4Z;8EkziJT9=Wy@{wcQ5vc|7(caJAkJ0rv!D^V8U72ZSI74hN_62Y`(E3;b?*lJje*~Vk$|_6K;V_uGi@7#@ zC1--=A{WD+HN+YB3Gk&r^-PDWfYxi}UxRjabG1i)a*dU@$Ka7`E%*8Gx3BfcT68wU z!>_aAIRc)%jM`C-t6 z{R#MQK=sIaSb?m(1ZY}T!%eoV{3oDp5;mX@sH|LI%gSZ89E8KR+z9{HmOJ1pgO>jJ z@bFDm`7DNC1}BKyX?R?PN6sJ@z?C3yFZl_77vv(p0Qar*$aLgv_J>?l$ z`LEzK@;P|J4OZR;;U|Em@eur_Dy!^mhlgyo?0xVlpk-3|Th*3)2!0=E+B@O+8aq$m ztzakd*#SQXc4$1|E}-#IUK+CW`{DFj(n7nJ0apOC9Kwfz>OT%oud~8c&JWY>p;P$| zpyjy{eg?dN{bBfHpnAICF{;eK~yNPxNH*UBRXdMZ`E%n3=d*#Ayq#0Rx9nd%g z;EJ1x1NO=f0@c3{{)H{K!=5i$_6hLiwpq!aM_;CH;D7!;`jJn-yN^(Qkd?cD-bdw>A6T;T z8$YDJ6V`F~%A=IO>lp*U+km=10pIa5^%Omg@clos($xfy|FPBP7s88yx?c^S2HFlN zPinE^Sp;tb?daJKKMl0LABPvdM!SjqVR&vUW#c~fi^BP@dt?N%@*Uu;#^Haqcdj9i zTxA@e)f(ev-5}eNYA>VP`UdT`SuG{h-EM?hQ_vE0)!?VByIZ5|CGGnvDXtK)AA+tL zWmh6pnVDSXJ`>w5M6fte)Kpr`+O?)2G?lglYBjc~k(TQ3IeAyRQW3<<2MzrBJ#*eO zCo|`H&NG?eO&<8hAFkeaJA4vJY#jg5tF&jwtM*GC;Byg@9C939@#EF83BQis&zL9S zACbhs3o!K{>w@+)+(5FgaDb$b1BVYW|MU^w{1es;_CELmlIw+V&t!G(MfhDL_bFrO zomu6a%um_(Va8(L1YUWToPwQ$zeF-`Pr%nbLhRE1dbs!JE4vBXXiobCe(+KHWAB3} zpT@5zxqkhM{EQ?{j>F4-P2QkghiA}@*k|GP->l+RxQ@hk8}Q=A<{ZMqs78O`_TMrl z_Ca{rv-pVnbRC{TvhPc4sY#8s_V0;d`U@j8!ydw0{=mMcSa&e{JjYMF4o@Tb-huGp z7g?wDISW7aC&tG<48I^Y?{z195s68{&)&l8sAV>$y#QnM3+y4h?edg%7JCD}M`s?0w<^3FNz9yv z6C~#={K{KWDlg68&bOxc9tQXI@NuM`H^KuuR<_iIjFI?d2%o+pr9NY8S5luGwd6S! zd>d-Z{X488xmOiF^p2FeS6x8us+5+{E^J-1YB%5~@+&*p#c#i(69_~v)6?7QGS zsLJzF;e(sDaJFd+FM3ys>m>i<1g}Mxa2*j=MYIb~Y}&%7(VRX)sZU?w_=Qr}zQS<| zr7peLLaA#m{e@D~z0R`>q11Di@r6=HUHS;6hPt#1rJlUB3#FF4vw^btx;Ua^HzBUWso)W#EADD_Ik7D~NMv4v98Q*5Es&lFoIwJ*gMN_|VQ zg;K{-Y@yV46kBoAmb!XUpHSL`Qqxb4S?F%sQde(+q|g6f|J@n5f#*?IxIB}DF?u`p zHl(2@+n2&8;UlPuouA44C*-iz{R$o2=|g(!vEZ0~>zI7W4JQOtXxE$F$3_d;jTeaJ

&*b-$&PDUyd@U&Gt_CJNOi;qgS#$ zfetF8yLm?Fqi>>xiZzGv;j`)=A;*Y~V_`u3yOuOFyUKV*F;6%cMewtno^jh)m)xPJfo zk*hzmYfV40|L7-gJ+!{@k(a)k40X-9vDkQ-WBi|PDsr7=0upO%O{}RkvldpSo9*h| zLbup0b?e}s7_p&{`m+uvNrCzyL?bUjXUbENkb$U*3 z*h_lj-n2LCEqcpdx}WLm{anA$FZRp*O25{x_nZAzztcDR!~Up0?oax&{=C2JYlF-n zJID?4gW{kxs0^xu`k*mr4cY@^;0#8CWH1>_2lK&Vz&{jG+mG*T+M1JbbSLi=ow8GP z>Q2*XJBBlK5@+JfoJFtvFyZZwUyVHiUrF($^$SQwg_F?BO<7R|C*HS1>6Y@3ETG!t`T&di0W zSs6>W@>bC*TUD!WHLbQ~SVJqp(=+_6bu;)mkDtrkYFFl>c9B6jh&e&O7 zw{v#hF4#r8WS8x#U9;j#?LEwJMffSra+6A}VS~u=QDPu&fHl#X*cWU+=5$jD{jqg zxGlHiI_}6FyHj`WF5R@3^>W00$*U0e4Pw4S%#XaWH}&S;(o6eUKj#zrMME;;zrzxJFydw z;&D8U=kYSu7F)Fdx1^1%kuwTL$*34LqhYj+j^P+1V{A-~xv@0TX4cG^1+!#U%$nIS zTV}^}%#k@Zr{>&TnrSO*<*b5LvMN^1YFI6+V>#A{EI9qAJ?->HD(lTv&f_38?yZQO zf7J@Dcbh5`4^1Ya9!nJLuKEI%Dt;qoL70bst-5u$>9$?N z9lD7-acAzr)x3*LkAy85-%b#A1tbTlU1^MWRkJ}1g^M0kVD-y!!; n$@|)bdwTvZ*T1T|0{%6V{%vl^GT(L9eZq>*lF8WZ-}d@51G9(y literal 0 HcmV?d00001 diff --git a/LightlessSync/lib/OtterTex.dll b/LightlessSync/lib/OtterTex.dll new file mode 100644 index 0000000000000000000000000000000000000000..c137aee184d789102648a05ecc24c0437df54ba1 GIT binary patch literal 42496 zcmdsg34EMY)%Us2JhNnGGRdTAo9@$eVcLe0t!)Yg(l%X!T|$zUv=owQGD!!rF_Uy* zDFYNl1fg1LL6#szDT1H^;sOQ)1qFncMf4R70*VNVzNml*-~XIvnXQ51`~H6K_kGjO z|2gN}bI-l^-2Fb!JXyQ>Dsm8!j_-#b65WR@e`*Ck8j=VOO#5zt?#_E+`h9B66Vscw z#S)?ZcwbjM(i3Wr^!D}*gtkUQ@xk6utT$A*zA4nx*AbncpYNS%nr>J@)NzSM;}7Ni z&PweqDh>Ixa-tzfIAhi)Bt%m2g=sAnY^#kJav;jpcClTOT9)vq(71$*3!^DX378rt zx{4h$%IDSxh-yX&J0fw2Xmy(6{DJ7s0r1D)2tb&W9py{^goxVa#}n~(2qkaBAh_UP z#y9O#YZp3AI10&m(8x*paYP3%A`;(@qWe+esakjooHkGOyFkYpHpiVP4U$B;G9ZI@`P+WnRPP zbq-%kweVbAV@%8w0x2TjV&Q1>I^Vn=V_wlNCA@;KG^X4o)rCR^$DQFFcZN@MoS~ml zL`5p#3|B(O>54T6ba$+IF6*K^+-!rILuZ|FXM`s~$6utm+zC+LFbvI`I~PX6RS1ZI z3|E5~_kn}S1u1f2id>W;(Ize3#VPXG6p4Da6qcmO<5T1bDe^>pLAt+|z&LRG>s1wh8s4(FEil-6ZH3EQ0gP3LLla)vLL} zpW=kaaXaG-&0T4xnKICrE#VxeXDzOUPeRmLN9(hT=Q;aOz1~?zyJp8xjs0`80?~#g zaCe9^J3p}u5tIK&Z}6qsIq!n!26@hV7V*AC{7VS<_X7}f*BMvs91$m`v(4F>?g($; zqBzPO1b3llqpf*eBYGG;#q0Ns_#E!=Y0wxq;vYBSojY+ix;%QEmXs^}@x!d69kb`NzB z4YIxVZt{brw?> zFpTH=aS{e+%H#MB-HCF+^{z6_H8+j{r9XjT%6s;9!}K|ePy~~#1{q5rY42Psv?dk0 zKtiS68Gda+8eTh(=Wy)HuE2n#yyJZHDqQZg1l-SjFqw0=xh1>_SusP{_@?ovo)Npj zJ-!9aPRc5FVwJhHQT>~>?3Q!;BiV(KJww?e4yQjpFub`bjWhTo=p$aD3}({ z5q{1H>QaA}UAQs)ycykrF^1O=R#^Dwsi^FGG!612KSlJxSk$=YNJOD}Y2rpimFqGcYAM8jNQ1X=re|+_&H;E>loLaCkccd_~F?#z;Jm>sF@_!UMVXwKQ{| zWpg>Uc4pi9_wst_Xn8$5+x`*d_432!_1tV*|6X1{Gg@AEXWKubyk2qGye4yPVazzP zHjrupml?J-!fa*LfB3>o)6zaLuN=1iQ>H%0rl6Z?Yfpx)ucUR)w4P^>(&sR@T*-zU zXwy8m6rjyzuLZ&{K*L-Ke5Bqlj!^HH>3X`1W1OyzZsTn2QPJhug>O?K$x$2+kX;%fK0!VS3a>cpXMp19d(&W`TNB4sR zpR$&4@bcNMhhGvqvV2BgbV+^tEFIk$(9wW@D>`0`m2Mgb8(HD#056*wJYJ2a^U4wE zyqcz?%l>jK@`A0EqsyzIQOS7XaXG`k6Ga&mI87&&AYaH^(3#TJUFgQY7eycPp1bCG z%}niZVSg~gerY!BBbZRk)y|I*lgE^-w)l03Q`PaOBQ}e*aX`0LQjeOpodmyF^C&Oh z?#i(KyCb$9(4Cg;$40aL%?#VmAE|B2?oUj+ne+AC8AgA5q(+_k8J69jns&3tsC)n0 zmLC_(S$#^zzkkyn zzdW2_9etJO%9XhCM+^5N_?!xi>(eGzc{K-S3mzVDCT0hOJrI;3Y}({7XBDSt(uvcHvD=_)nie_s^d|_pMCb)H*-MSAR)q^86Kon^Sqs^3`9{bv?vY!n(%Z z!EN}JXP$sFfVKR8rL;e$zTQr0erz88meR%H+FZ1w7>>&vXPn0*pSR6RiI`+jSxUqt zi;hl-m}JrXl!!^5$CMo8VC@j(85oB}r)%dNgRsl<(2^$#hL6Rf1*aJ2Kv|#AT7q-M z*%mYRB+VeFz2E9+JC8MR_`#)< zqf$mc$|mlez&}S=cAV5&WBszGla?v-&X*tM+g?4te127VRmEa3I6=prgRaB&Vg}BL zJeZJb(U)d44a8%;T?y7$-H&^ixX#$nM7M)3WO~(x`Z`>{5B{|x@XNaUwu0x6h9l8a z&cAnBUI5H{s)`rk3M3~7{}VPZgzS;R5g&BvIDAh9Ka4Ni#zoFw_{v|@uC#~gCU40& zeWqbX>{xQ5P3WHdanbB?-w(OyUBQROGkxXQ3qoV)S0N64d~9@riw=$d%M=$K7{^#C z^kShm3QiEoJ!3gk8^`*uO03^b`Ddw%&K<{i+SoaBT{Lej+kbxijgye>EXEyU8DAgA z*ffRlQ?nU=Ci&^|-8jih(ec+K*2=MM(_FMxa(K1izGBuq4mvKXp31md^qVHI{sEEy zG|agw7y0Xud+GPnIP|t6)}J_z=@I0|OV>?g`hp2e@0=D!Dc*qn0R3mdc+dwWtyd+M zKJnswFTEIM{b-2oI3)EglH%jR@56IM&-)mqRpEGl!q{Z#$u@U0{J_xPQo1vsxEB_M-bu??uKIK3QX?CDa?&Cxt~vGZnDYk3o=)3a{aTITLkU` z*n9&vqx4NPY^J2}OSm9XEK+)Cm82rdt0xD4rCK>rYq=zZbd zrDmj8NGDF>^gg81C53Sk&!M8iy(cLYnw&?%N|=N1egi8}^n)l;Ao?J{Da=)Wr;A|o z0vs1bsY=aNK5)NAKe~Xb!Of;OrkA>BQ_+Okz}u(Hal2g00%h)v$t#PG0dC7@9GSql zOX%|iH&0~AZ%P@j6v=+UTYzj~4&)EbtOCxSEjWddCNuuyD8^DCYc2wP!qj7dOQ$jZ z-Oo59pK&YD(pfR%1W4X2V0@^Mv9FYI%PhvXMgG`SrY{XMK3c^1yr5eoT|t)IHI4DB zqQ7?%)4vybp4hxs^gjn3E8P>OE`!dd*^GA8m942a}loo#U(08mKM6e|*eE$K7J97V0g&@_edTyHe;Lp}!^ccYK`By9AFD$@e7I6499+ z;7}hieob^v5mX|%N$C6%4&5p=#(vOW5xPL|3xb~$d`9qT!B$D-A)$XP__$z$SSu0y zE4<2mfyV^5?BQSiBmEV&YBY2Gw@9VBA{jI#x+1&61yWP;FTg2xJ;7-D(5 z(1SuJ3Rp5Yh4C-r8Jngt-Yt?n6PcbCW^{^tvFKkT5=}5~42Pa0nk^EmLFD`VEbkHd z_k_L;n96x0LcM;*SBe>1rZ6^y8TUb+(%i`MX^g7{n*__Iuw>CJ#*T@MEh1?MGd+;c z_zL7HJDVZdRvBKVN zp4nw?7hQzDsp$R*Ecv3~&r7!=ZgugeaWCZhLdHdfo$eqVD2Unlxw3dWbhhO)UNMF7 z=TjN4@iSg8^3ovFzbR!bB*p|4lsKCj3oH_RHm;0ZF%F$ z;@yy3<7ey+Fw&HA)gfNRTvDO1gFs*CU<|qA-99>F}amf54m-^-{fXYJLGoKBPLgecrJRvOL+(6!)#MINJmmJ$nhYF+4PXf zt%C={^n}Tk!Gm+@1(Q1&@yh6BlbZog%%?w@TnJXm>0Oh17x5}dFJ({crF-GQYAP_f zLBv}~6HV?mc(8`mC`o%m?@fHojnfX3D+#^k<{ga@<}0|zy~O7H)9!H}Z*xC|2T!oM zYIyKOn=69{Yi;fjJh+tjRxju5#}oeKUS@~gIsISm)h73a&>>y-oMdy8UApH}HrMIa zJ!@=kvq$%=O>ud;XPwRM^6H-THunG?h-$F8`<3oF+2)?nbWfwr-RICfO*Z!w^3rT` ztC5!tHus_K^=za@DbXdKe~pi;EwtI-q&IG%cAIn37D@=W-*frw37*sFLYtfBIh`&u zxhqO0cv|TilY6Fkf~Spc5$+PIL=TV9H*Ic~Cqly}S26Wy&sKUuxZkNSmR5lq5$;Fy z#nQ!|c5?Eu0R4z|2ji-P@`c;)DVTeLr-P=M+$3liP~N?qW1#a(7K&?vTk{D%>|s?(x!cUJ4eU>N(S{m2T?v^pYH`(0QVbtbnLT!MBQ96egH-7`RM8S$_qz05O6$IcQfGDhyCmBJb0?oQey-0#${ ziWTjoZNfcFf5hqEPTFT^(mAuP_3WgBCiiY|$g_)HvANqk=ixavPT>;IA4A{tB#EE2 zWA5?NlfbPuxs|iN5AJr8`&-~aaNjYx<4S(wxqyBooKY(m&{GCSYeG*V>o-1kC=KOh| zr#EbFeBRaM4|59p=`G*1yf4xkn>!}&TI#mBC3)A=0h?QuH$?ZD+?W09^S(^)*j#Jg zEmShcu=!C*|<~qG^(=8@9Cx4^&J$lsUI=xE0X>v~% zZuGiU;n7A4PZoB118RZEJzA3R7OMuEyTDtb`c3Zo@a5h~>Qa& z=6uWM1K!yxWO8%z@9@r1EjIUnw@mFcx!?F6_a39(x4B<=tJLIU*vejdrsNOaMXJ{1 zp7sCTd#q|Rxx9R*uU73bxrN|Xsas9%#X_fVoqF8lw4$-T4eA|}+dO@SZis_x1?yucd@$N=5F&{svbAFYXaZ!U8eLZ!^$;*@A)oQGfeK4nGgA{ zP;EB%Gv8iyfzAEGw@(e*-0ywYsFzL7G3&3s>(%6H!=_`_`@S31aVB@=REK{^?XQqUG9HT z4VheE){XvG)uSdiH1+HL*UdKZg;2A1*#8%s+fT#(zuMdt>c{^7vbjUrQ~tl%+#bg- z{qNe`%jzZndp7ru_Ll!cn|s~ypHg~k+{QS8#w?;odzryCe;5E)JH`ce+uSZa9Jtu#-f|unxWeWt-6sV; zue~kxeu;-yGuLSUGP#n8PP#^O<2f*%r+IC2tyXAqJ+qy3tv1o*jzhdJX){c21>${4 zJKE%KDv7J>w1p;jXNi-p(`rrbhSIpYUaL2`+e@8vz1D1U2dAy{+@N)t+*wn90`7d1 zJ1M~2u%?$^>q1|e7@A!Tc z_@?%h%{?EuNBgtQy&U+q_P)*iDezrwvfTaIPv7$YJ@9?4&gNVNKhWB3ZcM?0TEERr zEqGYFQaEFU^ds$n!BKB`cEOLdyG`zyIpqaE(OxjQH^wX}cvADo7HKc-owKUoX|2rU zzCPyLfoHWXCYL|wB5cUK<3;N+wO6ABAm6f5Ok#UCSYDb2z{T|zDS#j`0S ziiYO@4fMb1&r})l>mhi9?;9Qf8s11N@4sn(v|9eSbloLf3-<>Pk4ETbJmRisBWhF8 zm#|*f=y|0n^$QD?0aWThz7HK)?19-_Q z-6K<3el2d(!X~CdU2H1Klp8IEW4#qH(QY{`(ae5f&>YtxHg(ucwKprS2K|Lag@ulc zcKcSqC@I^|o0zGS@`I6jW~kMk|2HgTd&bJSQ3?Z7<=TN(p^+EoO_pfH5^gz$rhzPfq|}h} z+`%-D9!C<&sT=dia+&*>)@V6eFX!2?!!|8z96B`1N=rw6xV^E2Q)Kylpsey1h)+0_ zeZ}LzPIyKU%QNll#Olb3#iIn?~T2jXYQ3BIrhTyJTm{gW3ScUv;AiHIXCp<`g_XG zXniEt=4fddHF&uGo=W9NbVl#**>*wm)jC(RqL39pvDWY+#m zO2<9O+IR2@jWvzj8uPoulo;a!uRSb@Q94843gvp?dHmz;e7xq7<(XSlW}ew{Zm!aG zaznWl9hsfcHH$Fgl+Yo(eS)7N2?Cw?#V^y-fnK~FVP&Wg&ptCQ6|5ILS#XnJL@*|J zmf$YI&j82b^q#FIlfPXnjZOTa?<1Mn#Ni^$&q(`VEdie3bLZOPNLSN)^t z9pI0{N>!-mrd&ifsc^Ad)vJ{uzdEE|nO3B}p}MC|S7)h$xii&GurL?$wt{k1Og9%T zQjbYaz61S2{Cb_!nn{hpn3_p*aK2k6beYhsgLR>rHuVMdAQeo=*B(;`W-ruy_$BH_ zU}Y96YkyMT3c4{$Yo7r2Ig0BoR# zfz9-omWSsI?gF+!Cl61&e;2q7IzHU3-w3)Jnt3!Jp*tn?Tu6L$o`fdpNrdj9XMmT| z3&6|iMc`HRTi`zWJ@5d%0lbd>0vw{h0}s+afw!XZ=F#os1l~nn;N4UJypM{3-=_({ zhb3>1O6reG>Q5nWdGxI0>II=kMCTRJc};ZQ5cyl8^R|@n9hwSj@6!w*sW4DiM*}^o z66jZpfQ4!auvjexmZ+7$$?8+Ukm54VR1KiRY6GxLZ3dRBR$#U20M@7&aEa;#)~W=s zPVEG)R_6iNs0)D&>eIkxbvdv_?FDX8`+;qWz1N|>1bUkq0(Prgfc@$=;DEXlxKrH? zJXd`Om{bn{_oyENFIA5MFIP_huTuX3+^2pHJfMCByiUCY98$jn9#nq>-m2bm_~?-0 zn!R294U)Umd%(NZhrs)kuEX;x4>+s>z=zcs;G?Pp__&$^d`gw#N7YX&ZZprSqab-f zl>vXHDu5%3+rTSoA?VlCalkiJE$}V10{FH%3HXj$4}4!W14-Ki)V0%r9<3ed*R}x* zwH{!x76+DSJAjk5bAci40^m&T5@1;SEU-+w3RtdP4XoC#1=eUc>ONYmaT%9r2O+7| z4gu@5uS32<57?mH5BbR=Z`K}yq(%ENB%4ICMSC2QHtlI(hxR<=QIT)c z{u7dJZ3Nh_y$bnRA|KFRhh(SrXW+To+mN3p@}%}IBzv?EAh}p1mue0t>Q3_juhMYi znf8i&pH>9P0c||+I&CuK*Nc2en-0lAZ8q>$4L=W}Ln6OjD~IGRZ2=_T5Xs%zv5?%S zoe0ET4&bm>4}4f#2c5^Eq4DqsoZ31@vnoe?v6i63w?Y z9(mr;q6mFobjZ;QT6gRKdK^ih-|-n>p@YYzVh4{)C63QSGTHGZV93E^)JzADQDMhH zNXi^{0LvZs0;?TA0Mz7yq5^JZ#I!|exIcNeFuAW*uj2#*f9^*9u=QFEt`0s8Fz2=W>Q|DR>>s%vcI@d_KE;EA8 z-d-U3H9D7TiO!y^6Ul0^xkhZR6-k5GX%;&zu%po?y%#yzqDO(JiB1Plqo~NYiG`TR zyTwkw&ZRoHoX?2P<&yKOB}H?W3awD+G@R`e~_)E!PXZR_N1&j<^OP?-BYep+6(^6{2&s(ANmP*UOf# z5xhn44#B4de39oQ3y}>~kMQ7hwiG3hyELb>`FKCY zJ=%P{jbmEjeEbG244gr$fb*yUSVf-$9*1YF=hJe!4frW~5ZFXd0#79^IG@@D&!j@o z31Ah{>JKib&%*L@ycg^qZH1)XE|PYUw9^-84c1w#bCF0cf+SINvq)|h$<2^lTl7Qv z5_Co+pQJdqA;B4n<+VaD7rI_?8QVk>6FMpMMS??uH;d*W^+UuR7Re7qG9vWLLK8f9 zkLJ+Wen{vULe~ntTq~vR!R2@**FN`hjqS9FJSljQgkB_}Ln66Z;tmV_L!n26epzV3 zOpCM}Y}4Ujn<0_R5XlUY)QV)egMHN|bUQ-#xjP&;(}P7xkqikAJHAGt;IQLcv;g=$ z=#PkGL?k03A)R$dXC2a6CnS=PNJ1j16-li~YDLl}k~WdFi6kkKq)3t?84}5mNQOi* zERtc742xt$BqJgj5eYdZKTgSyQ}QE{kVrxzsTE0^U{Y{Ma9D6ekX&phBv@-A+pKl5 z%{GxFUF^dl!C}D>L2|P^Bv>ohCYTf)5*!vB5hRc33)be9qO~Ul+k9M)NfWHRAs@#Y790^Izt|D1^|PI}e5u)dZdXa6LjfsQfbE0=Y^PQvwIZn%NnL>Za+}E8 zMBXOy4v{BCo)mdfTM4Wi+ot*!y+FM$%sfsMDmJg>SI{HPB1x!OEDxkEI1-a#VijA)(W->b`*2mWHFa6 zSuAy0EOjc9A(0G;R z2u)MP{#3CqbV%r0p=*V16S__4q|iyBhlCyyT1{gMD#W-n#ADY|p+`bue>!8EU{Y{I zkV-`^m=xSo%ATYd(sE`<%bCF~N1q`c7I{eIA(7WYa&E9zB()-G6P-4Zw234sI!Tcv zMY3lG_oE@v91{7E$cII9SR}(D84;Zkk&KAs6^TnTIbE75Ih-kNdnT79G?P70D|D^U zZ9=yRofJAL^q!fL2aybkWLW57p&ype5s{3Dgl35+XGu*79TK{37Teh)ST~#L2oP%t zq4lHKZx7F9tXshNir~WwnLfCPab}Ik1rIJ}y5m?z{W!rT5-Paoc%~n2VO&8WcRjvq z@jV&e4ft-t_cVMX_(t)K;oF1nS@^nD$UPR{J25MN2VW1)63=$wln}rE-i_0uJ8~w-UEQyKtbU{3RPQTY8>3CoW@%;G30g5J++;sc;Ke#rVXf=H zc^S?Ou*#gk@1r4A@+vML6#fA?c_Pzi&1SrN0^{X^PnEt2x_!!DfX=z^0Ugsn06ta9 zl9Oh6_^xb0KJdhnalmvt^Y;ABVYpx40ek(W;co8HXoU`$JWg|ch zC!-EvBktB{v;lYSHQI>0Yjg_vfGv2h8FpSMg+`lk#;W1h_G3`SR-i^_;>~9oPUpr0 zdr(%5da=gWsE?)q|4!4O`46B*@8C>U(Yrv6-op++(LaG2PQzw_{ufZA59lb+9|AQL zbq-Lec|c7a4Roku5UK<5e2S`ocb}vxs-JW;Eugh@8P2zq+CcB|GcW2CDhcs*6Z!C# z7Nt%lKi;UK)M@lnymLgUR;tEZU6hJY8D1BnR68BQFJP4FqC2PaHHKfn@%2n9l4}qB zNbK~{I=tdVsk5j|>?P=A2_K{xc<+T$J18W2yXajBKL@{MWBGYBDBc{@ zynEEq!hc)!$@M$xal9!8_EnKw?^g|S{ek+NTpv{USp>p=sGbpfkEmz;%>P*B<@5D1 zwNS1k|1-5&{PVP`kn1zbE7#`~jpcYhSN{}yzfi|XzW!5fm2_TIN8#rb z3O(mU@y|1p}YQId(n0PM@dq)aNOrp*^4}&1M^tE<$Bw9OT-O)_dO3}1Cfr%Km?f{Xzh&m^|bDYwYT;}d-~$L zGNUZa%}J~`HV}(+$Ii~m%%WV?M0=z=<&a2s|F%dga@yM7w;jIf$}&(%AbW1+9+jPe!b3v)bZ zHJV-?qq|!%`(+loqC(#xsluZdNE67%}o{Miw@IaF_ZOW`leFUM(I^NEFGrE5@pmP=SEnNn?_oJ zxkr6Od2{2M<>lo|0CNhu73DR#dZRdit1!1b)e_~f4j66f zT9BJcnhUDxKEeUHF|(I?nKfEbzBsps4D@9cyF!$*yF_<%U;te%dvM7$_|fafYKbta zgK|B(fFl?S+j3gBv7&-9D#RM~jG2knVIGNW?T)rpQ2mOt2Clnn!npR?g+qi1k zQd$)qkn0*O|C(3fGywr}<@J6Of~dX=W0*lMTVAnY9R@U!FI!%j&Qzr{i&9Ku&8nJZ zHA`z!l4X!I9!AoVrqkSKq@9pUU9>ZTg`m)~5#fAsj!&syZg@=yWZf35>+5B@-d>nj z)MeIRs;MIcORBydxHd<30#lJHM~#FKC<9Zx9)yl+RAjNMvS z*F;U&jq#Nm1Yeu!lz60n16pM~-WT6MOE+!UD58y~$ly*fxl;%$vB79-EAir{KG9&W zPNfU6Fc;CvSoc6QzI)*v>q@$yCX(AEr+1f2?5W#=g_+?914MyS} z2u*nf?nQk!$VwtBm&Wpfho5M?yN{O|)Qo+02N^AgmJN1xM&o#y>tOr9Aes{FEH}3& zVA9-x5?jL`0#={WfOTcGeE>A}0M<^}$c8m~XGQPFCfFF(kq8FCShjt%9>|s;2-}=9 zTU(c*K$uup#-iO2*Tv#Uk9X$TK?B>cLfh5{CDb980`(D1>!iYF4CJXC%R*WjLDK+| zYmP_Sqm<~50cHD97u_zjaapkwLwd)W!Jb%eq!%h?O{N49ZsmXvEE*u z7gEB;XaeufZ67dd09@Z7#$DD$gWt9se;|}raDNl3uBuL&JO=ToWHZGj(8*F>Y^pM4PcmHYF%Mb3EvolIC~}sX;lzAKmD?#`?%wg44j^&XDZTQDR%G z*~7=SLq~~iEd>uB+YTKiwzai7d~7>3BX;&0i(9JQ8P`R6qSVV^PF$vcQFF2zz@#o~ z;|5+DXZmxL^)uJ`))!FYvzDAGi8W<3#|F|Bn9#F=UW>xq=e$^&M@=tj8ruz~fx zoK}QaB9ZPKt)^)EU_3Ukt05Zii6sti0Zpni)-{OrLq-A^S(`A{4eV;fGB=URl`OGW zbVqxly#w4#(}HA)ozmpdA_ix`Y-6-LvQwxub*p@s^Exn3rXpGaJcq|)UE9)vn$C>% zuj#|6nBdi7D$1JJ)_5eotG(u(lH^O*L>mR6a0jYS--tgb9yFDH&z7Ghe7##ct89a}L+Vw7); zcEML05@#@tSkHb{iL#)bSk`sTG4VZoyFB)-$L916%c(wEj%&Sr?r=7;8Th z8=qCn)wcT|U19{Q3c`zQGBTD#1#eA7ojyv(IPL#4GiNKK#wM7`B9F zv!msMiGjWzII_Bl_vIiK7(`>m!d2KKRxDgvu`pd?()EEvZGw5)nnbz}9?YB?3){9D8V zlUT57VWUZ>m&FLJt5|3dMvzULahDs%50r8AKpDpl#77W(Fk!|OdQ}={$xV}TWMLjq zpmSR7&IsMmEB6}Ht4}tHb_EXdDKM?*9Yn>Usn_=!-5TSyH3YAUN29&g7;G?D-lD-b z#kzX2`Z10PWXr+xA#ZN#uyU2Tm&|bkO^y3YU97WnP?XF8Vtspiw;_-N*s+W>L72N4i;%F=tpq8@t&! zY?qS>>WTI9pf3BK<=vRM;7K%5G*~p-rIjmVI|uuX^P@FbWJcnsS(7mK@+OluB1pYr zG-qY(vgPbHt91Q_(AZ4VQlnD1YE7z-E6H*>y$HVnj3DJ_*eTwr|e ziwu~wxrL~UMR5L@z`&m%^dD{?3CnrbyqPuWW(QlowI*eAF^y@KH_{2Ct??E%fm7Tb z+5AdcCY90BQp<+c%mebwLZ+#;XWdE|RZ-R~>1Xbk4bel^@Zy~p(+TEf5udQS12)>d z+(r}W)nau9?5%sbrE)wRM94CiaD-rW2>W>1KD#zlaQcghF%dO59HORiI0#JRQrjg% zS6WDeoa~sf(Nm2eqd!?{#w~Ay>*Nz|s*6W<)c1DCT#0cffdx32B zu0$+Be6-$eR2kNfYp{U>$BVxd#}AvB0o_Kp^b?-J6J*Tf30j6j&X(rh=12^}x|UV- z_D;rbUDo(GKeY`>VB9mFXhCu)8v6UYucd3s>)SWmCLFsmsVBg3bQ2X zY>KvK+NCK2m}c#8%(OPmsnxVpR;LS$u9hLiENp9FAz{<$^KX_gowaE!>ulPZf^C5{ z3)_Om%IZ}$DGn2D8e@}2w3f86N@K~8CgNKlc7)U%o!&Pxre*0xajYIQYmxhpNbBsI z;F$`ko8x5fwv5d(SMnn4gsPebx1v#|4K1>?T;JI#Qe$e6-rX44u`w3iVYETxRD;%I zZ06^COg5v5@hMu`vp-mHRjjJ0z=S6CnbEGYBnDeoRhcfgX_H7>HKt2!+8|QCHTbkC zBg4V1ojjh?`mJYRML`2{3PG6LBD_16VRv;@NbbNU8R4<&6hqv)&A4LwhaCzwL9qei ztu*iHFo?@NA)JdRaBGMK}--JmaB}n5e(zvD-I89)Y##PpaOKWV#B#cX1N8XT; zWEy9Y#x>P(g>OlX0MbcCXjM}acTh80YC)eSut?)-=G10{NbC4m;7T5iOP$$;nhX}r ztvxE?=3pIR51Px0Xonm|Kw<>SvV-^KYx=r)Pqwaa0NqJeteKsq-4yb=7Yj*y`(sUE z81$n%Gfp6_`AeQAGFLvIitdVbti@(`CoSvSnRcANl=uA9jw2Ui&u`sBU2H6vrIA@D zBos04sF=Gm^YkS%znKTS#$#|9+2hGXj=)p5)km|CdV=ck7Sh5qI(aG&smC=AZy_yw z6bq>*iVklfEu2ecpXC~Rck4C_*R)K%I74E(zBkGfF>~fR&6+K7KH3i{{z&CIJX0N_ z6%@z6K78jBO{*h3r;O*-+wo?E7Ti*d;;sG55z>Jtvb%}q)#GXG?Rd%?yIjaZz#ed2 z&&?RdBTr*Q@nFpeT7wiSE{XK9}3!@dQ`9#s+&dkH-z=F*HVbyvh*x-Fks< zMWIhXCnSbH6;qV_j7N9F8gdZOTmeK*UZI>WL@rPspWCsbFu0~bttfPONKH%LQRoQF za68gcK0JxX*^83+JtP137b?e8Dx8YHYCe_nk(*SuVY4XtSTOm7Gejr`G8K?QPATf@_>^ZHmX^ zawG4-Zns+tcC09LdP2m7z^?{EgfOkBEx20us-iZI+EEA(v0AXBAlS{(#=2cn_DpFY zrN)lQb87{`HSDKCw==lfV|cK@iR9p54?LVNZtOO#AiB=R%}B_aupXG-oXwyvKT^tpPmNlh_{P328`l zGt6TtdGM&ed_4HIf=QT9a6`oi17XAy)Y+=eAv)dyNIDC3Cj%SMLkzVHNd~s1vmG68 zKLbEVN8bR{GC&9M`Q1AB5AfjE2Mij6iw*Fz<2vUSZ&d(z8GHG&xI z>3BmL>3DS(>0=o9L34d9!#IZV4E!Xy&QF%>c!eG5cqbg`lNqKk;AO7(8`TWc8A=&u zFwA6_#eny?k{Vc@^+p!1(&(90P3PaNpn<@IA2${8vcDjE1i_#7~%{Gh6@=kV%WoQF~cPcAL8S2fZ`AT zmPbAww?%|3qShkVJby4CSwzSpYAr&3Uc@?P4*_8)fhZRx5Qa90221jmMHsdb#N9yb z;O9Ul!L61p8>X2E;mZ=E93WT-bjpQUBTW!VLp1fHCn=AqhHU7Fa3UaiV)Ed8mAou# zDoo!v&bQ{r)MnD&JCuboZ|lH?!wsYl$O9Vu@fV*n&L!7C?_c`SO)Y2%P_bH%Z$t9U|E2WO0dl1Da5~ENdV)G zOv5Y+mSDm{4Ahs2s$lO$7?7t*n!@W2dSX%JikGS5sWxJC~V=2>H24R$y}q`@%?^BZOq4VPdU z-uF`i!iNXT->6A!guaKUNEE25)9KAaWE8KZHkiGxb~2^;@KXqTgcbw`A#W zN!QcQqQuM%iU21fFQ@_JBtV$oOMJm*%+e@^#kOR!TMX8ZSqRDo zlg|uFlEInr%rG;WR8qh)A?blZ?O~C~71_a_;pI_L8_Utq%y#FI03SIKjy8uzS3qu~ zNuuUBlAl~=7y>0#Ub7UnX~7aWh3%~Yi5E$j&aeUl94OTz3WauUhyt6kst;`osao-! zk}mRXyVGM9qC1s!X(4DDW_B$WyqQDja<=^JdxVzwz~KEuCd1vwp-M`8dEDN)nANTj z>MB@`#i0n`i5gD`d0@hC3WH_-U`-xNg>r|`!4Mg3xv0$>Vn;(iSc6U^S7;g{ze^;3 zJGCW7YD-dvgS7}1!=Um*oa>^t5{wkJes4&(NZUY}VW2F{K%H%%&TqCc_E$AZU&vJ| zjS3#CVQ1L_q)_9B1b@p!B? z#Z0ncu7fng%(6AJ<(ZZt#mH+mG0PY?MN8M9jkLKJ;k+i?z>pjMgqJ0f8xIum4_1lB zDGJ)p356ay6sjz*#Eyn_DvUKB;V-(=d|~EQmM@M}FRZGnsE9^8YpOciI~I0ScWiAh zUsPSuUcRt$QAK-a`{D)I>fk5P9_s-EBK&aKdkT)x`gSCcAHq8tsUZ7t1UgnxICZnd zyp=XTU^`fC z4YtKcpvEbDoZ4ig<$0_JdAv=jCx7_(!3P`W=ZS>=8z$K-ZyHA)z;_5l+NUs$X0oxV zuIc<5fAF5J-K%a|`}fDDoI3tCUY;*Mb_;G=#1mWA?b=e;*Dg2x6I-lylp^r-&X%1E ztGAfV*bbQA-?23-ss9gHC7Vc`ZzMAdBh8(6jX84GTa9naFyW1i%o(3*2Om#t#2eyT zfh+LliYEN#em$)NUk_Xfnm_mJfBwMuy}xXlATh3dO2Hp#od#)R9Vq-YAe@fIVJC)@ zQ#=@rQ`AnJx$F1Np&RzCFEBSz}I$Xj<>4zts+&!UmmO1wP; z{Tpd?Nh~g9Kkbs7cHwQm1JKEph3F84s1muTBtE4gS|)i|Z^mN}a~`bPV$Sf^N9Cnb zeAWOPc$$U=LC`h1^%5>`~NT4Y*FE z*@2J_gm<9!+fl+rpeux0h}esSX1&FD!wH|f5n^(EEtOOID9X7NCEo@AjGhm!zyF=j F{{cAyTipNv literal 0 HcmV?d00001 diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index a7576db..e1b339e 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -105,12 +105,6 @@ "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" } }, - "Penumbra.String": { - "type": "Direct", - "requested": "[1.0.5, )", - "resolved": "1.0.5", - "contentHash": "+9YRQxwkzW6Ys/hx8vHvTwYV76QMjbf7Puq5SibxVLNzkPLyKLp7qZCKS1SC4yXPJlPB4g80IqxrxCm0yacMFw==" - }, "SixLabors.ImageSharp": { "type": "Direct", "requested": "[3.1.11, )", @@ -139,6 +133,24 @@ "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" }, + "FlatSharp.Compiler": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "MU6808xvdbWJ3Ev+5PKalqQuzvVbn1DzzQH8txRDHGFUNDvHjd+ejqpvnYc9BSJ8Qp8VjkkpJD8OzRYilbPp3A==" + }, + "FlatSharp.Runtime": { + "type": "Transitive", + "resolved": "7.9.0", + "contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==", + "dependencies": { + "System.Memory": "4.5.5" + } + }, + "JetBrains.Annotations": { + "type": "Transitive", + "resolved": "2024.3.0", + "contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug==" + }, "K4os.Compression.LZ4": { "type": "Transitive", "resolved": "1.3.8", @@ -508,6 +520,11 @@ "resolved": "9.0.3", "contentHash": "0nDJBZ06DVdTG2vvCZ4XjazLVaFawdT0pnji23ISX8I8fEOlRJyzH2I0kWiAbCtFwry2Zir4qE4l/GStLATfFw==" }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.5.5", + "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" + }, "System.Net.ServerSentEvents": { "type": "Transitive", "resolved": "9.0.3", @@ -524,8 +541,28 @@ "MessagePack.Annotations": "[3.1.3, )" } }, + "ottergui": { + "type": "Project", + "dependencies": { + "JetBrains.Annotations": "[2024.3.0, )", + "Microsoft.Extensions.DependencyInjection": "[9.0.2, )" + } + }, "penumbra.api": { "type": "Project" + }, + "penumbra.gamedata": { + "type": "Project", + "dependencies": { + "FlatSharp.Compiler": "[7.9.0, )", + "FlatSharp.Runtime": "[7.9.0, )", + "OtterGui": "[1.0.0, )", + "Penumbra.Api": "[5.12.0, )", + "Penumbra.String": "[1.0.6, )" + } + }, + "penumbra.string": { + "type": "Project" } } } diff --git a/PenumbraAPI b/PenumbraAPI deleted file mode 160000 index a2f8923..0000000 --- a/PenumbraAPI +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a2f89235464ea6cc25bb933325e8724b73312aa6 -- 2.49.1 From 28d9110cb061be2ee95df021b476315aef722377 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 25 Nov 2025 09:42:34 +0900 Subject: [PATCH 042/140] Restore logic --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 375 ++++-------------- .../Pairs/VisibleUserDataDistributor.cs | 169 ++------ 2 files changed, 114 insertions(+), 430 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index a0239c2..f00176d 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -70,8 +70,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly TextureDownscaleService _textureDownscaleService; private readonly PairStateCache _pairStateCache; private readonly PairManager _pairManager; - private Guid _currentDownloadOwnerToken; - private bool _downloadInProgress; private CancellationTokenSource? _applicationCancellationTokenSource = new(); private Guid _applicationId; private Task? _applicationTask; @@ -81,7 +79,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private CombatData? _dataReceivedInDowntime; private CancellationTokenSource? _downloadCancellationTokenSource = new(); private bool _forceApplyMods = false; - private bool _forceFullReapply; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -90,7 +87,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly object _pauseLock = new(); private Task _pauseTransitionTask = Task.CompletedTask; private bool _pauseRequested; - private int _restoreRequested; public string Ident { get; } public bool Initialized { get; private set; } @@ -106,7 +102,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _isVisible = value; if (!_isVisible) { - ResetRestoreState(); DisableSync(); ResetPenumbraCollection(reason: "VisibilityLost"); } @@ -226,19 +221,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Mediator.Subscribe(this, _ => EnableSync()); Mediator.Subscribe(this, _ => DisableSync()); Mediator.Subscribe(this, _ => EnableSync()); - Mediator.Subscribe(this, msg => + Mediator.Subscribe(this, msg => + { + if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) { - if (_charaHandler is null || !ReferenceEquals(msg.DownloadId, _charaHandler)) - { - return; - } - - if (_downloadManager.CurrentOwnerToken.HasValue - && _downloadManager.CurrentOwnerToken == _currentDownloadOwnerToken) - { - TryApplyQueuedData(); - } - }); + return; + } + TryApplyQueuedData(); + }); Initialized = true; } @@ -446,7 +436,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { EnsureInitialized(); LastReceivedCharacterData = data; - ResetRestoreState(); ApplyLastReceivedData(); } @@ -458,10 +447,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } LastReceivedCharacterData = data; - ResetRestoreState(); _cachedData = null; _forceApplyMods = true; - _forceFullReapply = true; LastAppliedDataBytes = -1; LastAppliedDataTris = -1; LastAppliedApproximateVRAMBytes = -1; @@ -474,10 +461,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (LastReceivedCharacterData is null) { Logger.LogTrace("No cached data to apply for {Ident}", Ident); - if (forced) - { - EnsureRestoredStateWhileWaitingForData("ForcedReapplyWithoutCache", skipIfAlreadyRestored: false); - } return; } @@ -493,7 +476,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { _forceApplyMods = true; _cachedData = null; - _forceFullReapply = true; LastAppliedDataBytes = -1; LastAppliedDataTris = -1; LastAppliedApproximateVRAMBytes = -1; @@ -513,7 +495,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); _cachedData = sanitized; - _forceFullReapply = true; return; } @@ -620,118 +601,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return data; } - private void ResetRestoreState() - { - Volatile.Write(ref _restoreRequested, 0); - } - - private void EnsureRestoredStateWhileWaitingForData(string reason, bool skipIfAlreadyRestored = true) - { - if (!IsVisible || _charaHandler is null || _charaHandler.Address == nint.Zero) - { - return; - } - - if (_cachedData is not null || LastReceivedCharacterData is not null) - { - return; - } - - if (!skipIfAlreadyRestored) - { - ResetRestoreState(); - } - else if (Volatile.Read(ref _restoreRequested) == 1) - { - return; - } - - if (Interlocked.CompareExchange(ref _restoreRequested, 1, 0) != 0) - { - return; - } - - var applicationId = Guid.NewGuid(); - _ = Task.Run(async () => - { - try - { - Logger.LogDebug("[{applicationId}] Restoring vanilla state for {handler} while waiting for data ({reason})", applicationId, GetLogIdentifier(), reason); - await RevertToRestoredAsync(applicationId).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogDebug(ex, "[{applicationId}] Failed to restore vanilla state for {handler} ({reason})", applicationId, GetLogIdentifier(), reason); - ResetRestoreState(); - } - }); - } - - private static Dictionary> BuildFullChangeSet(CharacterData characterData) - { - var result = new Dictionary>(); - - foreach (var objectKind in Enum.GetValues()) - { - var changes = new HashSet(); - - if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0) - { - changes.Add(PlayerChanges.ModFiles); - if (objectKind == ObjectKind.Player) - { - changes.Add(PlayerChanges.ForcedRedraw); - } - } - - if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) - { - changes.Add(PlayerChanges.Glamourer); - } - - if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize)) - { - changes.Add(PlayerChanges.Customize); - } - - if (objectKind == ObjectKind.Player) - { - if (!string.IsNullOrEmpty(characterData.ManipulationData)) - { - changes.Add(PlayerChanges.ModManip); - changes.Add(PlayerChanges.ForcedRedraw); - } - - if (!string.IsNullOrEmpty(characterData.HeelsData)) - { - changes.Add(PlayerChanges.Heels); - } - - if (!string.IsNullOrEmpty(characterData.HonorificData)) - { - changes.Add(PlayerChanges.Honorific); - } - - if (!string.IsNullOrEmpty(characterData.MoodlesData)) - { - changes.Add(PlayerChanges.Moodles); - } - - if (!string.IsNullOrEmpty(characterData.PetNamesData)) - { - changes.Add(PlayerChanges.PetNames); - } - } - - if (changes.Count > 0) - { - result[objectKind] = changes; - } - } - - return result; - } - private bool CanApplyNow() { return !_dalamudUtil.IsInCombat @@ -825,7 +694,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa this, forceApplyCustomization, forceApplyMods: false) .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; - _forceFullReapply = true; _cachedData = characterData; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); } @@ -847,11 +715,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, forceApplyCustomization, _forceApplyMods); - if (_forceFullReapply) - { - charaDataToUpdate = BuildFullChangeSet(characterData); - } - if (handlerReady && _forceApplyMods) { _forceApplyMods = false; @@ -870,9 +733,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); - var forcesReapply = _forceFullReapply || forceApplyCustomization || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0; - - DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forcesReapply, forceApplyCustomization); + DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); } public override string ToString() @@ -1064,185 +925,113 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forcePerformanceRecalc, bool forceApplyCustomization) + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) { if (!updatedData.Any()) { - if (forcePerformanceRecalc) - { - updatedData = BuildFullChangeSet(charaData); - } - - if (!updatedData.Any()) - { - Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); - _forceFullReapply = false; - return; - } + Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); + return; } var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); - if (_downloadInProgress) - { - Logger.LogDebug("[BASE-{appBase}] Download already in progress for {handler}, queueing data", applicationBase, GetLogIdentifier()); - EnqueueDeferredCharacterData(charaData, forceApplyCustomization || _forceApplyMods); - return; - } - _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; - var downloadOwnerToken = Guid.NewGuid(); - _currentDownloadOwnerToken = downloadOwnerToken; - _downloadInProgress = true; - - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, forcePerformanceRecalc, downloadOwnerToken, downloadToken).ConfigureAwait(false); + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false); } private Task? _pairDownloadTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, - bool updateModdedPaths, bool updateManip, bool forcePerformanceRecalc, Guid downloadOwnerToken, CancellationToken downloadToken) + bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) { - try + await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); + Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; + bool skipDownscaleForPair = ShouldSkipDownscale(); + var user = GetPrimaryUserData(); + + if (updateModdedPaths) { - await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); - Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; - bool skipDownscaleForPair = ShouldSkipDownscale(); - var user = GetPrimaryUserData(); + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - bool performedDownload = false; - - if (updateModdedPaths) + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - Logger.LogDebug("[BASE-{appBase}] Initial missing files for {handler}: {count}", applicationBase, GetLogIdentifier(), toDownloadReplacements.Count); - - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) - { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); - await _pairDownloadTask.ConfigureAwait(false); - } + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); + await _pairDownloadTask.ConfigureAwait(false); + } - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var currentHandler = _charaHandler; - var toDownloadFiles = await _downloadManager.InitiateDownloadList(currentHandler, toDownloadReplacements, downloadToken, downloadOwnerToken).ConfigureAwait(false); - Logger.LogDebug("[BASE-{appBase}] Download plan prepared for {handler}: {current} transfers, forbidden so far: {forbidden}", applicationBase, GetLogIdentifier(), toDownloadFiles.Count, _downloadManager.ForbiddenTransfers.Count); + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) { _downloadManager.ClearDownload(); - MarkApplicationDeferred(charaData); return; } - performedDownload = true; - - var handlerForDownload = currentHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); + var handlerForDownload = _charaHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); await _pairDownloadTask.ConfigureAwait(false); if (downloadToken.IsCancellationRequested) { Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - MarkApplicationDeferred(charaData); return; } - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); - Logger.LogDebug("[BASE-{appBase}] Re-evaluating missing files for {handler}: {count} remaining after attempt {attempt}", applicationBase, GetLogIdentifier(), toDownloadReplacements.Count, attempts); - } - - if (!performedDownload) - { - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List())) + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) { - _downloadManager.ClearDownload(); - MarkApplicationDeferred(charaData); - return; + break; } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) { - MarkApplicationDeferred(charaData); return; } } - else if (forcePerformanceRecalc) + + downloadToken.ThrowIfCancellationRequested(); + + var handlerForApply = _charaHandler; + if (handlerForApply is null || handlerForApply.Address == nint.Zero) { - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List())) - { - MarkApplicationDeferred(charaData); - return; - } - - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) - { - MarkApplicationDeferred(charaData); - return; - } + Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + return; } - downloadToken.ThrowIfCancellationRequested(); - - var handlerForApply = _charaHandler; - if (handlerForApply is null || handlerForApply.Address == nint.Zero) - { - Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - MarkApplicationDeferred(charaData); - return; - } - - var appToken = _applicationCancellationTokenSource?.Token; - while ((!_applicationTask?.IsCompleted ?? false) - && !downloadToken.IsCancellationRequested - && (!appToken?.IsCancellationRequested ?? false)) - { - // block until current application is done - Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); - await Task.Delay(250).ConfigureAwait(false); - } - - if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) - { - MarkApplicationDeferred(charaData); - return; - } - - _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); - var token = _applicationCancellationTokenSource.Token; - - _forceFullReapply = false; - _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); - } - catch (OperationCanceledException ex) when (downloadToken.IsCancellationRequested || ex.CancellationToken == downloadToken) + var appToken = _applicationCancellationTokenSource?.Token; + while ((!_applicationTask?.IsCompleted ?? false) + && !downloadToken.IsCancellationRequested + && (!appToken?.IsCancellationRequested ?? false)) { - Logger.LogDebug("[BASE-{appBase}] Download cancelled for {handler}", applicationBase, GetLogIdentifier()); - MarkApplicationDeferred(charaData); + Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); + await Task.Delay(250).ConfigureAwait(false); } - finally + + if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { - _downloadInProgress = false; + return; } + + _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); + var token = _applicationCancellationTokenSource.Token; + + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, @@ -1265,7 +1054,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (penumbraCollection == Guid.Empty) { Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); - MarkApplicationDeferred(charaData); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); return; } } @@ -1282,7 +1072,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!objIndex.HasValue) { Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); - MarkApplicationDeferred(charaData); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); return; } @@ -1325,22 +1116,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Application finished", _applicationId); } - catch (OperationCanceledException ex) when (ex.CancellationToken == token || token.IsCancellationRequested) + catch (OperationCanceledException) { - Logger.LogDebug("[{applicationId}] Application cancelled via request token for {handler}", _applicationId, GetLogIdentifier()); - MarkApplicationDeferred(charaData); - } - catch (OperationCanceledException ex) - { - MarkApplicationDeferred(charaData); - Logger.LogDebug("[{applicationId}] Application deferred; redraw or apply operation cancelled ({reason}) for {handler}", _applicationId, ex.Message, GetLogIdentifier()); + Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); } catch (Exception ex) { if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) { IsVisible = false; - MarkApplicationDeferred(charaData); + _forceApplyMods = true; + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } else @@ -1403,7 +1192,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa else { Logger.LogTrace("{handler} visibility changed, now: {visi}, no cached or received data exists", GetLogIdentifier(), IsVisible); - EnsureRestoredStateWhileWaitingForData("VisibleWithoutCachedData"); } } else if (_charaHandler?.Address == nint.Zero && IsVisible) @@ -1735,27 +1523,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa pending.CharacterData, pending.Forced); } - private void MarkApplicationDeferred(CharacterData charaData) - { - _forceApplyMods = true; - _forceFullReapply = true; - _currentDownloadOwnerToken = Guid.Empty; - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - EnqueueDeferredCharacterData(charaData); - } - - private void EnqueueDeferredCharacterData(CharacterData charaData, bool forced = true) - { - try - { - _dataReceivedInDowntime = new(Guid.NewGuid(), charaData.DeepClone(), forced); - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Failed to queue deferred data for {handler}", GetLogIdentifier()); - } - } } internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index da53332..1840813 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -1,17 +1,16 @@ using System; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Comparer; -using LightlessSync.PlayerData.Pairs; -using LightlessSync.Utils; -using LightlessSync.Services.Mediator; -using LightlessSync.Services; -using LightlessSync.WebAPI; -using LightlessSync.WebAPI.Files; -using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using LightlessSync.API.Data; +using LightlessSync.API.Data.Comparer; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using LightlessSync.Utils; +using LightlessSync.WebAPI; +using LightlessSync.WebAPI.Files; +using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; @@ -28,9 +27,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private readonly HashSet _usersToPushDataTo = new(UserDataComparer.Instance); private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1); private readonly CancellationTokenSource _runtimeCts = new(); - private readonly Dictionary _lastPushedHashes = new(StringComparer.Ordinal); - private readonly object _pushSync = new(); - public VisibleUserDataDistributor(ILogger logger, ApiController apiController, DalamudUtilService dalamudUtil, PairLedger pairLedger, LightlessMediator mediator, FileUploadManager fileTransferManager) : base(logger, mediator) @@ -56,7 +52,14 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase }); Mediator.Subscribe(this, (_) => PushToAllVisibleUsers()); - Mediator.Subscribe(this, (_) => HandleDisconnected()); + Mediator.Subscribe(this, (_) => + { + _fileTransferManager.CancelUpload(); + _previouslyVisiblePlayers.Clear(); + _usersToPushDataTo.Clear(); + _uploadingCharacterData = null; + _fileUploadTask = null; + }); } protected override void Dispose(bool disposing) @@ -72,18 +75,15 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private void PushToAllVisibleUsers(bool forced = false) { - lock (_pushSync) + foreach (var user in GetVisibleUsers()) { - foreach (var user in GetVisibleUsers()) - { - _usersToPushDataTo.Add(user); - } + _usersToPushDataTo.Add(user); + } - if (_usersToPushDataTo.Count > 0) - { - Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count); - PushCharacterData_internalLocked(forced); - } + if (_usersToPushDataTo.Count > 0) + { + Logger.LogDebug("Pushing data {hash} for {count} visible players", _lastCreatedData?.DataHash.Value ?? "UNKNOWN", _usersToPushDataTo.Count); + PushCharacterData(forced); } } @@ -92,9 +92,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase if (!_dalamudUtil.GetIsPlayerPresent() || !_apiController.IsConnected) return; var allVisibleUsers = GetVisibleUsers(); - var newVisibleUsers = allVisibleUsers - .Except(_previouslyVisiblePlayers, UserDataComparer.Instance) - .ToList(); + var newVisibleUsers = allVisibleUsers.Except(_previouslyVisiblePlayers, UserDataComparer.Instance).ToList(); _previouslyVisiblePlayers.Clear(); _previouslyVisiblePlayers.AddRange(allVisibleUsers); if (newVisibleUsers.Count == 0) return; @@ -102,115 +100,48 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase Logger.LogDebug("Scheduling character data push of {data} to {users}", _lastCreatedData?.DataHash.Value ?? string.Empty, string.Join(", ", newVisibleUsers.Select(k => k.AliasOrUID))); - lock (_pushSync) + foreach (var user in newVisibleUsers) { - foreach (var user in newVisibleUsers) - { - _usersToPushDataTo.Add(user); - } - PushCharacterData_internalLocked(); + _usersToPushDataTo.Add(user); } + PushCharacterData(); } private void PushCharacterData(bool forced = false) - { - lock (_pushSync) - { - PushCharacterData_internalLocked(forced); - } - } - - private void PushCharacterData_internalLocked(bool forced = false) { if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; - if (!_apiController.IsConnected || !_fileTransferManager.IsReady) - { - Logger.LogTrace("Skipping character push. Connected: {connected}, UploadManagerReady: {ready}", - _apiController.IsConnected, _fileTransferManager.IsReady); - return; - } _ = Task.Run(async () => { try { - Task? uploadTask; - bool forcedPush; - lock (_pushSync) - { - if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; - forcedPush = forced | (_uploadingCharacterData?.DataHash != _lastCreatedData.DataHash); + forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash; - if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forcedPush) - { - _uploadingCharacterData = _lastCreatedData.DeepClone(); - Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", - _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forcedPush); - _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); - } + if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced) + { + _uploadingCharacterData = _lastCreatedData.DeepClone(); + Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", + _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forced); + _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); + } - uploadTask = _fileUploadTask; - } - - var dataToSend = await uploadTask.ConfigureAwait(false); - var dataHash = dataToSend.DataHash.Value; + if (_fileUploadTask != null) + { + var dataToSend = await _fileUploadTask.ConfigureAwait(false); await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); try { - List recipients; - bool shouldSkip = false; - lock (_pushSync) - { - if (_usersToPushDataTo.Count == 0) return; - recipients = forcedPush - ? _usersToPushDataTo.ToList() - : _usersToPushDataTo - .Where(user => !_lastPushedHashes.TryGetValue(user.UID, out var sentHash) || !string.Equals(sentHash, dataHash, StringComparison.Ordinal)) - .ToList(); - - if (recipients.Count == 0 && !forcedPush) - { - Logger.LogTrace("All recipients already have character data hash {hash}, skipping push.", dataHash); - _usersToPushDataTo.Clear(); - shouldSkip = true; - } - } - - if (shouldSkip) - return; - - Logger.LogDebug("Pushing {data} to {users}", dataHash, string.Join(", ", recipients.Select(k => k.AliasOrUID))); - await _apiController.PushCharacterData(dataToSend, recipients).ConfigureAwait(false); - - lock (_pushSync) - { - foreach (var user in recipients) - { - _lastPushedHashes[user.UID] = dataHash; - _usersToPushDataTo.Remove(user); - } - - if (!forcedPush && _usersToPushDataTo.Count > 0) - { - foreach (var satisfied in _usersToPushDataTo - .Where(user => _lastPushedHashes.TryGetValue(user.UID, out var sentHash) && string.Equals(sentHash, dataHash, StringComparison.Ordinal)) - .ToList()) - { - _usersToPushDataTo.Remove(satisfied); - } - } - - if (forcedPush) - { - _usersToPushDataTo.Clear(); - } - } + if (_usersToPushDataTo.Count == 0) return; + Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID))); + await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false); + _usersToPushDataTo.Clear(); } finally { _pushDataSemaphore.Release(); } } + } catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested) { Logger.LogDebug("PushCharacterData cancelled"); @@ -222,20 +153,6 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase }); } - private void HandleDisconnected() - { - _fileTransferManager.CancelUpload(); - _previouslyVisiblePlayers.Clear(); - - lock (_pushSync) - { - _usersToPushDataTo.Clear(); - _lastPushedHashes.Clear(); - _uploadingCharacterData = null; - _fileUploadTask = null; - } - } - private List GetVisibleUsers() { return _pairLedger.GetVisiblePairs() -- 2.49.1 From d057c638ab1fcf915a3f1c83785c2d7c128e7327 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 25 Nov 2025 11:22:53 +0900 Subject: [PATCH 043/140] temp fix --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index f00176d..2114c35 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -79,6 +79,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private CombatData? _dataReceivedInDowntime; private CancellationTokenSource? _downloadCancellationTokenSource = new(); private bool _forceApplyMods = false; + private bool _forceFullReapply; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -495,6 +496,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { Logger.LogTrace("Handler for {Ident} not visible, caching sanitized data for later", Ident); _cachedData = sanitized; + _forceFullReapply = true; return; } @@ -695,6 +697,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa .Any(p => p.Value.Contains(PlayerChanges.ModManip) || p.Value.Contains(PlayerChanges.ModFiles)); _forceApplyMods = hasDiffMods || _forceApplyMods || _cachedData == null; _cachedData = characterData; + _forceFullReapply = true; Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); } @@ -733,7 +736,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); - DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate); + var forceFullReapply = _forceFullReapply || forceApplyCustomization + || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0; + + DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply); } public override string ToString() @@ -925,12 +931,86 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData) + private static Dictionary> BuildFullChangeSet(CharacterData characterData) + { + var result = new Dictionary>(); + + foreach (var objectKind in Enum.GetValues()) + { + var changes = new HashSet(); + + if (characterData.FileReplacements.TryGetValue(objectKind, out var replacements) && replacements.Count > 0) + { + changes.Add(PlayerChanges.ModFiles); + if (objectKind == ObjectKind.Player) + { + changes.Add(PlayerChanges.ForcedRedraw); + } + } + + if (characterData.GlamourerData.TryGetValue(objectKind, out var glamourer) && !string.IsNullOrEmpty(glamourer)) + { + changes.Add(PlayerChanges.Glamourer); + } + + if (characterData.CustomizePlusData.TryGetValue(objectKind, out var customize) && !string.IsNullOrEmpty(customize)) + { + changes.Add(PlayerChanges.Customize); + } + + if (objectKind == ObjectKind.Player) + { + if (!string.IsNullOrEmpty(characterData.ManipulationData)) + { + changes.Add(PlayerChanges.ModManip); + changes.Add(PlayerChanges.ForcedRedraw); + } + + if (!string.IsNullOrEmpty(characterData.HeelsData)) + { + changes.Add(PlayerChanges.Heels); + } + + if (!string.IsNullOrEmpty(characterData.HonorificData)) + { + changes.Add(PlayerChanges.Honorific); + } + + if (!string.IsNullOrEmpty(characterData.MoodlesData)) + { + changes.Add(PlayerChanges.Moodles); + } + + if (!string.IsNullOrEmpty(characterData.PetNamesData)) + { + changes.Add(PlayerChanges.PetNames); + } + } + + if (changes.Count > 0) + { + result[objectKind] = changes; + } + } + + return result; + } + + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forceFullReapply) { if (!updatedData.Any()) { - Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); - return; + if (forceFullReapply) + { + updatedData = BuildFullChangeSet(charaData); + } + + if (!updatedData.Any()) + { + Logger.LogDebug("[BASE-{appBase}] Nothing to update for {obj}", applicationBase, GetLogIdentifier()); + _forceFullReapply = false; + return; + } } var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); @@ -1011,6 +1091,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; return; } @@ -1025,6 +1106,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { + _forceFullReapply = true; return; } @@ -1056,6 +1138,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogTrace("[BASE-{applicationId}] Penumbra collection unavailable for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; return; } } @@ -1074,6 +1157,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{applicationId}] GameObject not available for {handler}, caching data for later application", applicationBase, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; return; } @@ -1105,6 +1189,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); @@ -1121,6 +1206,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; } catch (Exception ex) { @@ -1130,11 +1216,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _forceApplyMods = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } else { Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); + _forceFullReapply = true; } } } -- 2.49.1 From 961092ab873040af44bb34ff985edd8615097ec9 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 25 Nov 2025 21:58:14 -0600 Subject: [PATCH 044/140] Re-add Penumbra and OtterGui submodules --- .gitmodules | 3 +++ OtterGui | 1 + Penumbra.Api | 1 + Penumbra.GameData | 1 + Penumbra.String | 1 + 5 files changed, 7 insertions(+) create mode 160000 OtterGui create mode 160000 Penumbra.Api create mode 160000 Penumbra.GameData create mode 160000 Penumbra.String diff --git a/.gitmodules b/.gitmodules index 7879cd2..a399752 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,9 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git +[submodule "PenumbraAPI"] + path = PenumbraAPI + url = https://github.com/Ottermandias/Penumbra.Api.git [submodule "Penumbra.GameData"] path = Penumbra.GameData url = https://github.com/Ottermandias/Penumbra.GameData diff --git a/OtterGui b/OtterGui new file mode 160000 index 0000000..18e62ab --- /dev/null +++ b/OtterGui @@ -0,0 +1 @@ +Subproject commit 18e62ab2d8b9ac7028a33707eb35f8f9c61f245a diff --git a/Penumbra.Api b/Penumbra.Api new file mode 160000 index 0000000..704d62f --- /dev/null +++ b/Penumbra.Api @@ -0,0 +1 @@ +Subproject commit 704d62f64f791b8cfd42363beaa464ad6f98ae48 diff --git a/Penumbra.GameData b/Penumbra.GameData new file mode 160000 index 0000000..1bb6210 --- /dev/null +++ b/Penumbra.GameData @@ -0,0 +1 @@ +Subproject commit 1bb6210d7cba0e3091bbb5a13b901c62e72280e9 diff --git a/Penumbra.String b/Penumbra.String new file mode 160000 index 0000000..4aac62e --- /dev/null +++ b/Penumbra.String @@ -0,0 +1 @@ +Subproject commit 4aac62e73b89a0c538a7a0a5c22822f15b13c0cc -- 2.49.1 From 1e6109d1e6e788dbb181b7d7cd049d344827fcff Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 25 Nov 2025 21:59:14 -0600 Subject: [PATCH 045/140] Remove PenumbraAPI submodule --- .gitmodules | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitmodules b/.gitmodules index a399752..5a1af2c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,6 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git -[submodule "PenumbraAPI"] - path = PenumbraAPI - url = https://github.com/Ottermandias/Penumbra.Api.git [submodule "Penumbra.GameData"] path = Penumbra.GameData url = https://github.com/Ottermandias/Penumbra.GameData @@ -15,4 +12,4 @@ url = https://github.com/Ottermandias/Penumbra.String [submodule "OtterGui"] path = OtterGui - url = https://github.com/Ottermandias/OtterGui + url = https://github.com/Ottermandias/OtterGui \ No newline at end of file -- 2.49.1 From 01607c275a35402c4aa7935d83e94f9afb1a2727 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 25 Nov 2025 22:02:07 -0600 Subject: [PATCH 046/140] Remove broken Penumbra and OtterGui submodules --- .gitmodules | 12 ------------ OtterGui | 1 - Penumbra.Api | 1 - Penumbra.GameData | 1 - Penumbra.String | 1 - 5 files changed, 16 deletions(-) delete mode 160000 OtterGui delete mode 160000 Penumbra.Api delete mode 160000 Penumbra.GameData delete mode 160000 Penumbra.String diff --git a/.gitmodules b/.gitmodules index 5a1af2c..34f6277 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,3 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git -[submodule "Penumbra.GameData"] - path = Penumbra.GameData - url = https://github.com/Ottermandias/Penumbra.GameData -[submodule "Penumbra.Api"] - path = Penumbra.Api - url = https://github.com/Ottermandias/Penumbra.Api -[submodule "Penumbra.String"] - path = Penumbra.String - url = https://github.com/Ottermandias/Penumbra.String -[submodule "OtterGui"] - path = OtterGui - url = https://github.com/Ottermandias/OtterGui \ No newline at end of file diff --git a/OtterGui b/OtterGui deleted file mode 160000 index 18e62ab..0000000 --- a/OtterGui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 18e62ab2d8b9ac7028a33707eb35f8f9c61f245a diff --git a/Penumbra.Api b/Penumbra.Api deleted file mode 160000 index 704d62f..0000000 --- a/Penumbra.Api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 704d62f64f791b8cfd42363beaa464ad6f98ae48 diff --git a/Penumbra.GameData b/Penumbra.GameData deleted file mode 160000 index 1bb6210..0000000 --- a/Penumbra.GameData +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1bb6210d7cba0e3091bbb5a13b901c62e72280e9 diff --git a/Penumbra.String b/Penumbra.String deleted file mode 160000 index 4aac62e..0000000 --- a/Penumbra.String +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4aac62e73b89a0c538a7a0a5c22822f15b13c0cc -- 2.49.1 From 7a9ade95c30970a96f013d5a460ff9cb8b37ed42 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 25 Nov 2025 22:08:14 -0600 Subject: [PATCH 047/140] Remove PenumbraAPI submodule completely --- PenumbraAPI | 1 - 1 file changed, 1 deletion(-) delete mode 160000 PenumbraAPI diff --git a/PenumbraAPI b/PenumbraAPI deleted file mode 160000 index a2f8923..0000000 --- a/PenumbraAPI +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a2f89235464ea6cc25bb933325e8724b73312aa6 -- 2.49.1 From e350e8007a6f38f2c694b727a057a9ed6cc79b30 Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 25 Nov 2025 22:22:35 -0600 Subject: [PATCH 048/140] Fixing submodules --- .gitmodules | 12 ++ LightlessSync.sln | 119 ++++++++++-------- LightlessSync/LightlessSync.csproj | 2 +- LightlessSync/Services/ContextMenuService.cs | 4 - LightlessSync/WebAPI/SignalR/ApiController.cs | 40 ------ OtterGui | 1 + Penumbra.Api | 1 + Penumbra.GameData | 1 + Penumbra.String | 1 + PenumbraAPI/packages.lock.json | 13 ++ 10 files changed, 95 insertions(+), 99 deletions(-) create mode 160000 OtterGui create mode 160000 Penumbra.Api create mode 160000 Penumbra.GameData create mode 160000 Penumbra.String create mode 100644 PenumbraAPI/packages.lock.json diff --git a/.gitmodules b/.gitmodules index 34f6277..7879cd2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,15 @@ [submodule "LightlessAPI"] path = LightlessAPI url = https://git.lightless-sync.org/Lightless-Sync/LightlessAPI.git +[submodule "Penumbra.GameData"] + path = Penumbra.GameData + url = https://github.com/Ottermandias/Penumbra.GameData +[submodule "Penumbra.Api"] + path = Penumbra.Api + url = https://github.com/Ottermandias/Penumbra.Api +[submodule "Penumbra.String"] + path = Penumbra.String + url = https://github.com/Ottermandias/Penumbra.String +[submodule "OtterGui"] + path = OtterGui + url = https://github.com/Ottermandias/OtterGui diff --git a/LightlessSync.sln b/LightlessSync.sln index 6aba7e9..8d92b53 100644 --- a/LightlessSync.sln +++ b/LightlessSync.sln @@ -1,6 +1,7 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32328.378 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11217.181 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{585B740D-BA2C-429B-9CF3-B2D223423748}" ProjectSection(SolutionItems) = preProject @@ -11,90 +12,100 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync", "LightlessS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessSync.API", "LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj", "{A4E42AFA-5045-7E81-937F-3A320AC52987}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{65ACC53A-1D72-40D4-A99E-7D451D87E182}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.Api", "Penumbra.Api\Penumbra.Api.csproj", "{22AE06C8-5139-45D2-A5F9-E76C019050D9}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{4D466894-0F1E-4808-A3E8-3FC9DE954AC6}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumbra.GameData\Penumbra.GameData.csproj", "{3C016B19-2A2C-4068-9378-B9B805605EFB}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{719723E1-8218-495A-98BA-4D0BDF7822EB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OtterGui", "OtterGui", "{F30CFB00-531B-5698-C50F-38FBF3471340}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGuiInternal", "OtterGui\OtterGuiInternal\OtterGuiInternal.csproj", "{DF590F45-F26C-4337-98DE-367C97253125}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{C77A2833-3FE4-405B-811D-439B1FF859D9}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.ActiveCfg = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|Any CPU.Build.0 = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.ActiveCfg = Debug|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x64.Build.0 = Debug|x64 + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Debug|x86.Build.0 = Debug|Any CPU {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.ActiveCfg = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|Any CPU.Build.0 = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.ActiveCfg = Release|x64 {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x64.Build.0 = Release|x64 + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.ActiveCfg = Release|Any CPU + {BB929046-4CD2-B174-EBAA-C756AC3AC8DA}.Release|x86.Build.0 = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|Any CPU.Build.0 = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.ActiveCfg = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x64.Build.0 = Debug|Any CPU + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.ActiveCfg = Debug|Any CPU + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Debug|x86.Build.0 = Debug|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.ActiveCfg = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|Any CPU.Build.0 = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.ActiveCfg = Release|Any CPU {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x64.Build.0 = Release|Any CPU - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.ActiveCfg = Debug|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|Any CPU.Build.0 = Debug|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.ActiveCfg = Debug|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Debug|x64.Build.0 = Debug|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.ActiveCfg = Release|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|Any CPU.Build.0 = Release|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.ActiveCfg = Release|x64 - {CCB659C7-3D2D-4FB1-856E-98FDA8E5A6D3}.Release|x64.Build.0 = Release|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.ActiveCfg = Debug|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|Any CPU.Build.0 = Debug|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.ActiveCfg = Debug|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Debug|x64.Build.0 = Debug|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.ActiveCfg = Release|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|Any CPU.Build.0 = Release|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.ActiveCfg = Release|x64 - {65ACC53A-1D72-40D4-A99E-7D451D87E182}.Release|x64.Build.0 = Release|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.ActiveCfg = Debug|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|Any CPU.Build.0 = Debug|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.ActiveCfg = Debug|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Debug|x64.Build.0 = Debug|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.ActiveCfg = Release|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|Any CPU.Build.0 = Release|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.ActiveCfg = Release|x64 - {4D466894-0F1E-4808-A3E8-3FC9DE954AC6}.Release|x64.Build.0 = Release|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.ActiveCfg = Debug|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|Any CPU.Build.0 = Debug|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.ActiveCfg = Debug|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Debug|x64.Build.0 = Debug|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.ActiveCfg = Release|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|Any CPU.Build.0 = Release|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.ActiveCfg = Release|x64 - {719723E1-8218-495A-98BA-4D0BDF7822EB}.Release|x64.Build.0 = Release|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.ActiveCfg = Debug|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Debug|Any CPU.Build.0 = Debug|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.ActiveCfg = Debug|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Debug|x64.Build.0 = Debug|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.ActiveCfg = Release|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Release|Any CPU.Build.0 = Release|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.ActiveCfg = Release|x64 - {DF590F45-F26C-4337-98DE-367C97253125}.Release|x64.Build.0 = Release|x64 + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.ActiveCfg = Release|Any CPU + {A4E42AFA-5045-7E81-937F-3A320AC52987}.Release|x86.Build.0 = Release|Any CPU + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.ActiveCfg = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|Any CPU.Build.0 = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.ActiveCfg = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x64.Build.0 = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.ActiveCfg = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Debug|x86.Build.0 = Debug|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.ActiveCfg = Release|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|Any CPU.Build.0 = Release|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.ActiveCfg = Release|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x64.Build.0 = Release|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.ActiveCfg = Release|x64 + {82DFB180-DD4C-4C48-919C-B5C5C77FC0FD}.Release|x86.Build.0 = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.ActiveCfg = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|Any CPU.Build.0 = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.ActiveCfg = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x64.Build.0 = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.ActiveCfg = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Debug|x86.Build.0 = Debug|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.ActiveCfg = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|Any CPU.Build.0 = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.ActiveCfg = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x64.Build.0 = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.ActiveCfg = Release|x64 + {22AE06C8-5139-45D2-A5F9-E76C019050D9}.Release|x86.Build.0 = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.ActiveCfg = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|Any CPU.Build.0 = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.ActiveCfg = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x64.Build.0 = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.ActiveCfg = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Debug|x86.Build.0 = Debug|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.ActiveCfg = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|Any CPU.Build.0 = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.ActiveCfg = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x64.Build.0 = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.ActiveCfg = Release|x64 + {3C016B19-2A2C-4068-9378-B9B805605EFB}.Release|x86.Build.0 = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.ActiveCfg = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|Any CPU.Build.0 = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.ActiveCfg = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x64.Build.0 = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.ActiveCfg = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Debug|x86.Build.0 = Debug|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.ActiveCfg = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|Any CPU.Build.0 = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.ActiveCfg = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.Build.0 = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.ActiveCfg = Release|x64 + {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {719723E1-8218-495A-98BA-4D0BDF7822EB} = {F30CFB00-531B-5698-C50F-38FBF3471340} - {DF590F45-F26C-4337-98DE-367C97253125} = {F30CFB00-531B-5698-C50F-38FBF3471340} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B17E85B1-5F60-4440-9F9A-3DDE877E8CDF} EndGlobalSection diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index ec722c5..13f5104 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -10,7 +10,7 @@ - net9.0-windows7.0 + net9.0-windows x64 enable latest diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 4555f3a..8c45474 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -238,10 +238,6 @@ internal class ContextMenuService : IHostedService _mediator.Publish(new NotificationMessage(title, message, type, TimeSpan.FromSeconds(durationSeconds))); } - private HashSet VisibleUserIds => [.. _pairManager.DirectPairs - .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) - .Select(u => (ulong)u.PlayerCharacterId)]; - private bool CanOpenLightfinderProfile(string hashedCid) { if (!_broadcastService.IsBroadcasting) diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index ec4025f..ff3e896 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -740,45 +740,5 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL ServerState = state; } - - public Task UserGetLightfinderProfile(string hashedCid) - { - throw new NotImplementedException(); - } - - public Task UpdateChatPresence(ChatPresenceUpdateDto presence) - { - throw new NotImplementedException(); - } - - public Task Client_ChatReceive(ChatMessageDto message) - { - throw new NotImplementedException(); - } - - public Task> GetZoneChatChannels() - { - throw new NotImplementedException(); - } - - public Task> GetGroupChatChannels() - { - throw new NotImplementedException(); - } - - public Task SendChatMessage(ChatSendRequestDto request) - { - throw new NotImplementedException(); - } - - public Task ReportChatMessage(ChatReportSubmitDto request) - { - throw new NotImplementedException(); - } - - public Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) - { - throw new NotImplementedException(); - } } #pragma warning restore MA0040 diff --git a/OtterGui b/OtterGui new file mode 160000 index 0000000..18e62ab --- /dev/null +++ b/OtterGui @@ -0,0 +1 @@ +Subproject commit 18e62ab2d8b9ac7028a33707eb35f8f9c61f245a diff --git a/Penumbra.Api b/Penumbra.Api new file mode 160000 index 0000000..704d62f --- /dev/null +++ b/Penumbra.Api @@ -0,0 +1 @@ +Subproject commit 704d62f64f791b8cfd42363beaa464ad6f98ae48 diff --git a/Penumbra.GameData b/Penumbra.GameData new file mode 160000 index 0000000..1bb6210 --- /dev/null +++ b/Penumbra.GameData @@ -0,0 +1 @@ +Subproject commit 1bb6210d7cba0e3091bbb5a13b901c62e72280e9 diff --git a/Penumbra.String b/Penumbra.String new file mode 160000 index 0000000..4aac62e --- /dev/null +++ b/Penumbra.String @@ -0,0 +1 @@ +Subproject commit 4aac62e73b89a0c538a7a0a5c22822f15b13c0cc diff --git a/PenumbraAPI/packages.lock.json b/PenumbraAPI/packages.lock.json new file mode 100644 index 0000000..bd07e56 --- /dev/null +++ b/PenumbraAPI/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.25, )", + "resolved": "1.2.25", + "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" + } + } + } +} \ No newline at end of file -- 2.49.1 From 1cdc0a90f9df5ef7afff262189a1be67591df6b3 Mon Sep 17 00:00:00 2001 From: azyges Date: Wed, 26 Nov 2025 17:56:01 +0900 Subject: [PATCH 049/140] basic summaries for all pair classes, create partial classes and condense models into a single file --- .../Pairs/IPairPerformanceSubject.cs | 3 + .../PlayerData/Pairs/OptionalPluginWarning.cs | 10 - LightlessSync/PlayerData/Pairs/Pair.cs | 3 + .../Pairs/PairCoordinator.Groups.cs | 136 ++++++ .../PlayerData/Pairs/PairCoordinator.Users.cs | 303 +++++++++++++ .../PlayerData/Pairs/PairCoordinator.cs | 426 +----------------- .../PlayerData/Pairs/PairHandlerAdapter.cs | 3 + .../PlayerData/Pairs/PairHandlerRegistry.cs | 236 +++------- LightlessSync/PlayerData/Pairs/PairLedger.cs | 3 + LightlessSync/PlayerData/Pairs/PairManager.cs | 3 + .../Pairs/{PairState.cs => PairModels.cs} | 141 ++++-- .../PlayerData/Pairs/PairStateCache.cs | 3 + .../Pairs/VisibleUserDataDistributor.cs | 3 + 13 files changed, 626 insertions(+), 647 deletions(-) delete mode 100644 LightlessSync/PlayerData/Pairs/OptionalPluginWarning.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs rename LightlessSync/PlayerData/Pairs/{PairState.cs => PairModels.cs} (69%) diff --git a/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs index a11893b..cd62f98 100644 --- a/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs +++ b/LightlessSync/PlayerData/Pairs/IPairPerformanceSubject.cs @@ -2,6 +2,9 @@ using LightlessSync.API.Data; namespace LightlessSync.PlayerData.Pairs; +///

+/// performance metrics for each pair handler +/// public interface IPairPerformanceSubject { string Ident { get; } diff --git a/LightlessSync/PlayerData/Pairs/OptionalPluginWarning.cs b/LightlessSync/PlayerData/Pairs/OptionalPluginWarning.cs deleted file mode 100644 index a5c5eff..0000000 --- a/LightlessSync/PlayerData/Pairs/OptionalPluginWarning.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace LightlessSync.PlayerData.Pairs; - -public record OptionalPluginWarning -{ - public bool ShownHeelsWarning { get; set; } = false; - public bool ShownCustomizePlusWarning { get; set; } = false; - public bool ShownHonorificWarning { get; set; } = false; - public bool ShownMoodlesWarning { get; set; } = false; - public bool ShowPetNicknamesWarning { get; set; } = false; -} \ No newline at end of file diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 7709b06..87933ac 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -14,6 +14,9 @@ using LightlessSync.WebAPI; namespace LightlessSync.PlayerData.Pairs; +/// +/// ui wrapper around a pair connection +/// public class Pair { private readonly PairLedger _pairLedger; diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs new file mode 100644 index 0000000..7bdfc23 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Groups.cs @@ -0,0 +1,136 @@ +using LightlessSync.API.Dto.Group; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +/// +/// handles group related pair events +/// +public sealed partial class PairCoordinator +{ + public void HandleGroupChangePermissions(GroupPermissionDto dto) + { + var result = _pairManager.UpdateGroupPermissions(dto); + if (!result.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update permissions for group {GroupId}: {Error}", dto.Group.GID, result.Error); + } + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupFullInfo(GroupFullInfoDto dto) + { + var result = _pairManager.AddGroup(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add group {GroupId}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairJoined(GroupPairFullInfoDto dto) + { + var result = _pairManager.AddOrUpdateGroupPair(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add group pair {Uid}/{Group}: {Error}", dto.User.UID, dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairLeft(GroupPairDto dto) + { + var deregistration = _pairManager.RemoveGroupPair(dto); + if (deregistration.Success && deregistration.Value is { } registration && registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + else if (!deregistration.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RemoveGroupPair failed for {Uid}: {Error}", dto.User.UID, deregistration.Error); + } + + if (deregistration.Success) + { + PublishPairDataChanged(groupChanged: true); + } + } + + public void HandleGroupRemoved(GroupDto dto) + { + var removalResult = _pairManager.RemoveGroup(dto.Group.GID); + if (removalResult.Success) + { + foreach (var registration in removalResult.Value) + { + if (registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + } + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to remove group {Group}: {Error}", dto.Group.GID, removalResult.Error); + } + + if (removalResult.Success) + { + PublishPairDataChanged(groupChanged: true); + } + } + + public void HandleGroupInfoUpdate(GroupInfoDto dto) + { + var result = _pairManager.UpdateGroupInfo(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group info for {Group}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairPermissions(GroupPairUserPermissionDto dto) + { + var result = _pairManager.UpdateGroupPairPermissions(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group pair permissions for {Group}: {Error}", dto.Group.GID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } + + public void HandleGroupPairStatus(GroupPairUserInfoDto dto, bool isSelf) + { + PairOperationResult result; + if (isSelf) + { + result = _pairManager.UpdateGroupStatus(dto); + } + else + { + result = _pairManager.UpdateGroupPairStatus(dto); + } + + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update group status for {Group}:{Uid}: {Error}", dto.GID, dto.UID, result.Error); + return; + } + + PublishPairDataChanged(groupChanged: true); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs new file mode 100644 index 0000000..5925663 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs @@ -0,0 +1,303 @@ +using LightlessSync.API.Data; +using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Extensions; +using LightlessSync.API.Dto.User; +using LightlessSync.Services.Events; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +/// +/// handles user pair events +/// +public sealed partial class PairCoordinator +{ + public void HandleUserAddPair(UserPairDto dto, bool addToLastAddedUser = true) + { + var result = _pairManager.AddOrUpdateIndividual(dto, addToLastAddedUser); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add/update pair {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserAddPair(UserFullPairDto dto) + { + var result = _pairManager.AddOrUpdateIndividual(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to add/update full pair {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserRemovePair(UserDto dto) + { + var removal = _pairManager.RemoveIndividual(dto); + if (removal.Success && removal.Value is { } registration && registration.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); + } + else if (!removal.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RemoveIndividual failed for {Uid}: {Error}", dto.User.UID, removal.Error); + } + + if (removal.Success) + { + _pendingCharacterData.TryRemove(dto.User.UID, out _); + PublishPairDataChanged(); + } + } + + public void HandleUserStatus(UserIndividualPairStatusDto dto) + { + var result = _pairManager.SetIndividualStatus(dto); + if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update individual pair status for {Uid}: {Error}", dto.User.UID, result.Error); + return; + } + + PublishPairDataChanged(); + } + + public void HandleUserOnline(OnlineUserIdentDto dto, bool sendNotification) + { + var wasOnline = false; + PairConnection? previousConnection = null; + if (_pairManager.TryGetPair(dto.User.UID, out var existingConnection)) + { + previousConnection = existingConnection; + wasOnline = existingConnection.IsOnline; + } + + var registrationResult = _pairManager.MarkOnline(dto); + if (!registrationResult.Success) + { + _logger.LogDebug("MarkOnline failed for {Uid}: {Error}", dto.User.UID, registrationResult.Error); + return; + } + + var registration = registrationResult.Value; + if (registration.CharacterIdent is null) + { + _logger.LogDebug("Online registration for {Uid} missing ident.", dto.User.UID); + } + else + { + var handlerResult = _handlerRegistry.RegisterOnlinePair(registration); + if (!handlerResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("RegisterOnlinePair failed for {Uid}: {Error}", dto.User.UID, handlerResult.Error); + } + } + + var connectionResult = _pairManager.GetPair(dto.User.UID); + var connection = connectionResult.Success ? connectionResult.Value : previousConnection; + if (connection is not null) + { + _mediator.Publish(new ClearProfileUserDataMessage(connection.User)); + } + else + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + } + + if (!wasOnline) + { + NotifyUserOnline(connection, sendNotification); + } + + if (registration.CharacterIdent is not null && + _pendingCharacterData.TryRemove(dto.User.UID, out var pendingData)) + { + var pendingRegistration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), registration.CharacterIdent); + var pendingApply = _handlerRegistry.ApplyCharacterData(pendingRegistration, pendingData); + if (!pendingApply.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Applying pending character data for {Uid} failed: {Error}", dto.User.UID, pendingApply.Error); + } + } + + PublishPairDataChanged(); + } + + public void HandleUserOffline(UserData user) + { + var registrationResult = _pairManager.MarkOffline(user); + if (registrationResult.Success) + { + _pendingCharacterData.TryRemove(user.UID, out _); + if (registrationResult.Value.CharacterIdent is not null) + { + _ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value); + } + + _mediator.Publish(new ClearProfileUserDataMessage(user)); + PublishPairDataChanged(); + } + else if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("MarkOffline failed for {Uid}: {Error}", user.UID, registrationResult.Error); + } + } + + public void HandleUserPermissions(UserPermissionsDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Permission update received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + var previous = connection.OtherToSelfPermissions; + + var updateResult = _pairManager.UpdateOtherPermissions(dto); + if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); + return; + } + + PublishPairDataChanged(); + + if (previous.IsPaused() != dto.Permissions.IsPaused()) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + + if (connection.Ident is not null) + { + var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); + if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); + } + } + } + + if (!connection.IsPaused && connection.Ident is not null) + { + ReapplyLastKnownData(dto.User.UID, connection.Ident); + } + } + + public void HandleSelfPermissions(UserPermissionsDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Self permission update received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + var previous = connection.SelfToOtherPermissions; + + var updateResult = _pairManager.UpdateSelfPermissions(dto); + if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update self permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); + return; + } + + PublishPairDataChanged(); + + if (previous.IsPaused() != dto.Permissions.IsPaused()) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + + if (connection.Ident is not null) + { + var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); + if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); + } + } + } + + if (!connection.IsPaused && connection.Ident is not null) + { + ReapplyLastKnownData(dto.User.UID, connection.Ident); + } + } + + public void HandleUploadStatus(UserDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Upload status received for unknown pair {Uid}", dto.User.UID); + } + return; + } + + var connection = pairResult.Value; + if (connection.Ident is null) + { + return; + } + + var setResult = _handlerRegistry.SetUploading(new PairUniqueIdentifier(dto.User.UID), connection.Ident, true); + if (!setResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Failed to set uploading for {Uid}: {Error}", dto.User.UID, setResult.Error); + } + } + + public void HandleCharacterData(OnlineUserCharaDataDto dto) + { + var pairResult = _pairManager.GetPair(dto.User.UID); + if (!pairResult.Success) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Character data received for unknown pair {Uid}, queued for later.", dto.User.UID); + } + _pendingCharacterData[dto.User.UID] = dto; + return; + } + + var connection = pairResult.Value; + _mediator.Publish(new EventMessage(new Event(connection.User, nameof(PairCoordinator), EventSeverity.Informational, "Received Character Data"))); + if (connection.Ident is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Character data received for {Uid} without ident, queued for later.", dto.User.UID); + } + _pendingCharacterData[dto.User.UID] = dto; + return; + } + + _pendingCharacterData.TryRemove(dto.User.UID, out _); + var registration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident); + var applyResult = _handlerRegistry.ApplyCharacterData(registration, dto); + if (!applyResult.Success && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("ApplyCharacterData queued for {Uid}: {Error}", dto.User.UID, applyResult.Error); + } + } + + public void HandleProfile(UserDto dto) + { + _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs index ddc4adb..b7af0cd 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs @@ -1,21 +1,19 @@ using System; using System.Collections.Concurrent; -using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; -using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto.CharaData; -using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.Services.Mediator; using LightlessSync.Services.Events; +using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; -public sealed class PairCoordinator : MediatorSubscriberBase +/// +/// wires mediator events into the pair system +/// +public sealed partial class PairCoordinator : MediatorSubscriberBase { private readonly ILogger _logger; private readonly LightlessConfigService _configService; @@ -107,45 +105,6 @@ public sealed class PairCoordinator : MediatorSubscriberBase } } - public void HandleGroupChangePermissions(GroupPermissionDto dto) - { - var result = _pairManager.UpdateGroupPermissions(dto); - if (!result.Success) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update permissions for group {GroupId}: {Error}", dto.Group.GID, result.Error); - } - return; - } - - PublishPairDataChanged(groupChanged: true); - } - - public void HandleGroupFullInfo(GroupFullInfoDto dto) - { - var result = _pairManager.AddGroup(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to add group {GroupId}: {Error}", dto.Group.GID, result.Error); - return; - } - - PublishPairDataChanged(groupChanged: true); - } - - public void HandleGroupPairJoined(GroupPairFullInfoDto dto) - { - var result = _pairManager.AddOrUpdateGroupPair(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to add group pair {Uid}/{Group}: {Error}", dto.User.UID, dto.Group.GID, result.Error); - return; - } - - PublishPairDataChanged(groupChanged: true); - } - private void HandleActiveServerChange(string serverUrl) { if (_logger.IsEnabled(LogLevel.Debug)) @@ -175,379 +134,4 @@ public sealed class PairCoordinator : MediatorSubscriberBase _mediator.Publish(new ClearProfileGroupDataMessage()); PublishPairDataChanged(groupChanged: true); } - - public void HandleGroupPairLeft(GroupPairDto dto) - { - var deregistration = _pairManager.RemoveGroupPair(dto); - if (deregistration.Success && deregistration.Value is { } registration && registration.CharacterIdent is not null) - { - _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); - } - else if (!deregistration.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("RemoveGroupPair failed for {Uid}: {Error}", dto.User.UID, deregistration.Error); - } - - if (deregistration.Success) - { - PublishPairDataChanged(groupChanged: true); - } - } - - public void HandleGroupRemoved(GroupDto dto) - { - var removalResult = _pairManager.RemoveGroup(dto.Group.GID); - if (removalResult.Success) - { - foreach (var registration in removalResult.Value) - { - if (registration.CharacterIdent is not null) - { - _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); - } - } - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to remove group {Group}: {Error}", dto.Group.GID, removalResult.Error); - } - - if (removalResult.Success) - { - PublishPairDataChanged(groupChanged: true); - } - } - - public void HandleGroupInfoUpdate(GroupInfoDto dto) - { - var result = _pairManager.UpdateGroupInfo(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update group info for {Group}: {Error}", dto.Group.GID, result.Error); - return; - } - - PublishPairDataChanged(groupChanged: true); - } - - public void HandleGroupPairPermissions(GroupPairUserPermissionDto dto) - { - var result = _pairManager.UpdateGroupPairPermissions(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update group pair permissions for {Group}: {Error}", dto.Group.GID, result.Error); - return; - } - - PublishPairDataChanged(groupChanged: true); - } - - public void HandleGroupPairStatus(GroupPairUserInfoDto dto, bool isSelf) - { - PairOperationResult result; - if (isSelf) - { - result = _pairManager.UpdateGroupStatus(dto); - } - else - { - result = _pairManager.UpdateGroupPairStatus(dto); - } - - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update group status for {Group}:{Uid}: {Error}", dto.GID, dto.UID, result.Error); - return; - } - - PublishPairDataChanged(groupChanged: true); - } - - public void HandleUserAddPair(UserPairDto dto, bool addToLastAddedUser = true) - { - var result = _pairManager.AddOrUpdateIndividual(dto, addToLastAddedUser); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to add/update pair {Uid}: {Error}", dto.User.UID, result.Error); - return; - } - - PublishPairDataChanged(); - } - - public void HandleUserAddPair(UserFullPairDto dto) - { - var result = _pairManager.AddOrUpdateIndividual(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to add/update full pair {Uid}: {Error}", dto.User.UID, result.Error); - return; - } - - PublishPairDataChanged(); - } - - public void HandleUserRemovePair(UserDto dto) - { - var removal = _pairManager.RemoveIndividual(dto); - if (removal.Success && removal.Value is { } registration && registration.CharacterIdent is not null) - { - _ = _handlerRegistry.DeregisterOfflinePair(registration, forceDisposal: true); - } - else if (!removal.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("RemoveIndividual failed for {Uid}: {Error}", dto.User.UID, removal.Error); - } - - if (removal.Success) - { - _pendingCharacterData.TryRemove(dto.User.UID, out _); - PublishPairDataChanged(); - } - } - - public void HandleUserStatus(UserIndividualPairStatusDto dto) - { - var result = _pairManager.SetIndividualStatus(dto); - if (!result.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update individual pair status for {Uid}: {Error}", dto.User.UID, result.Error); - return; - } - - PublishPairDataChanged(); - } - - public void HandleUserOnline(OnlineUserIdentDto dto, bool sendNotification) - { - var wasOnline = false; - PairConnection? previousConnection = null; - if (_pairManager.TryGetPair(dto.User.UID, out var existingConnection)) - { - previousConnection = existingConnection; - wasOnline = existingConnection.IsOnline; - } - - var registrationResult = _pairManager.MarkOnline(dto); - if (!registrationResult.Success) - { - _logger.LogDebug("MarkOnline failed for {Uid}: {Error}", dto.User.UID, registrationResult.Error); - return; - } - - var registration = registrationResult.Value; - if (registration.CharacterIdent is null) - { - _logger.LogDebug("Online registration for {Uid} missing ident.", dto.User.UID); - } - else - { - var handlerResult = _handlerRegistry.RegisterOnlinePair(registration); - if (!handlerResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("RegisterOnlinePair failed for {Uid}: {Error}", dto.User.UID, handlerResult.Error); - } - } - - var connectionResult = _pairManager.GetPair(dto.User.UID); - var connection = connectionResult.Success ? connectionResult.Value : previousConnection; - if (connection is not null) - { - _mediator.Publish(new ClearProfileUserDataMessage(connection.User)); - } - else - { - _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - } - - if (!wasOnline) - { - NotifyUserOnline(connection, sendNotification); - } - - if (registration.CharacterIdent is not null && - _pendingCharacterData.TryRemove(dto.User.UID, out var pendingData)) - { - var pendingRegistration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), registration.CharacterIdent); - var pendingApply = _handlerRegistry.ApplyCharacterData(pendingRegistration, pendingData); - if (!pendingApply.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Applying pending character data for {Uid} failed: {Error}", dto.User.UID, pendingApply.Error); - } - } - - PublishPairDataChanged(); - } - - public void HandleUserOffline(UserData user) - { - var registrationResult = _pairManager.MarkOffline(user); - if (registrationResult.Success) - { - _pendingCharacterData.TryRemove(user.UID, out _); - if (registrationResult.Value.CharacterIdent is not null) - { - _ = _handlerRegistry.DeregisterOfflinePair(registrationResult.Value); - } - - _mediator.Publish(new ClearProfileUserDataMessage(user)); - PublishPairDataChanged(); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("MarkOffline failed for {Uid}: {Error}", user.UID, registrationResult.Error); - } - } - - public void HandleUserPermissions(UserPermissionsDto dto) - { - var pairResult = _pairManager.GetPair(dto.User.UID); - if (!pairResult.Success) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Permission update received for unknown pair {Uid}", dto.User.UID); - } - return; - } - - var connection = pairResult.Value; - var previous = connection.OtherToSelfPermissions; - - var updateResult = _pairManager.UpdateOtherPermissions(dto); - if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); - return; - } - - PublishPairDataChanged(); - - if (previous.IsPaused() != dto.Permissions.IsPaused()) - { - _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - - if (connection.Ident is not null) - { - var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); - if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); - } - } - } - - if (!connection.IsPaused && connection.Ident is not null) - { - ReapplyLastKnownData(dto.User.UID, connection.Ident); - } - } - - public void HandleSelfPermissions(UserPermissionsDto dto) - { - var pairResult = _pairManager.GetPair(dto.User.UID); - if (!pairResult.Success) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Self permission update received for unknown pair {Uid}", dto.User.UID); - } - return; - } - - var connection = pairResult.Value; - var previous = connection.SelfToOtherPermissions; - - var updateResult = _pairManager.UpdateSelfPermissions(dto); - if (!updateResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update self permissions for {Uid}: {Error}", dto.User.UID, updateResult.Error); - return; - } - - PublishPairDataChanged(); - - if (previous.IsPaused() != dto.Permissions.IsPaused()) - { - _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - - if (connection.Ident is not null) - { - var pauseResult = _handlerRegistry.SetPausedState(new PairUniqueIdentifier(dto.User.UID), connection.Ident, dto.Permissions.IsPaused()); - if (!pauseResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to update pause state for {Uid}: {Error}", dto.User.UID, pauseResult.Error); - } - } - } - - if (!connection.IsPaused && connection.Ident is not null) - { - ReapplyLastKnownData(dto.User.UID, connection.Ident); - } - } - - public void HandleUploadStatus(UserDto dto) - { - var pairResult = _pairManager.GetPair(dto.User.UID); - if (!pairResult.Success) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Upload status received for unknown pair {Uid}", dto.User.UID); - } - return; - } - - var connection = pairResult.Value; - if (connection.Ident is null) - { - return; - } - - var setResult = _handlerRegistry.SetUploading(new PairUniqueIdentifier(dto.User.UID), connection.Ident, true); - if (!setResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Failed to set uploading for {Uid}: {Error}", dto.User.UID, setResult.Error); - } - } - - public void HandleCharacterData(OnlineUserCharaDataDto dto) - { - var pairResult = _pairManager.GetPair(dto.User.UID); - if (!pairResult.Success) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Character data received for unknown pair {Uid}, queued for later.", dto.User.UID); - } - _pendingCharacterData[dto.User.UID] = dto; - return; - } - - var connection = pairResult.Value; - _mediator.Publish(new EventMessage(new Event(connection.User, nameof(PairCoordinator), EventSeverity.Informational, "Received Character Data"))); - if (connection.Ident is null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Character data received for {Uid} without ident, queued for later.", dto.User.UID); - } - _pendingCharacterData[dto.User.UID] = dto; - return; - } - - _pendingCharacterData.TryRemove(dto.User.UID, out _); - var registration = new PairRegistration(new PairUniqueIdentifier(dto.User.UID), connection.Ident); - var applyResult = _handlerRegistry.ApplyCharacterData(registration, dto); - if (!applyResult.Success && _logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("ApplyCharacterData queued for {Uid}: {Error}", dto.User.UID, applyResult.Error); - } - } - - public void HandleProfile(UserDto dto) - { - _mediator.Publish(new ClearProfileUserDataMessage(dto.User)); - } } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 2114c35..6950186 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -28,6 +28,9 @@ using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Pairs; +/// +/// orchestrates the lifecycle of a paired character +/// public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject { string Ident { get; } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index 6c43119..48d3c9e 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; @@ -11,12 +10,14 @@ using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; +/// +/// creates, tracks, and removes pair handlers +/// public sealed class PairHandlerRegistry : IDisposable { private readonly object _gate = new(); - private readonly Dictionary _identToHandler = new(StringComparer.Ordinal); - private readonly Dictionary> _handlerToPairs = new(); - private readonly Dictionary _waitingRequests = new(StringComparer.Ordinal); + private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); + private readonly Dictionary _entriesByHandler = new(); private readonly IPairHandlerAdapterFactory _handlerFactory; private readonly PairManager _pairManager; @@ -24,7 +25,6 @@ public sealed class PairHandlerRegistry : IDisposable private readonly ILogger _logger; private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5); - private readonly TimeSpan _waitForHandlerGracePeriod = TimeSpan.FromMinutes(2); public PairHandlerRegistry( IPairHandlerAdapterFactory handlerFactory, @@ -42,7 +42,7 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - return _handlerToPairs.Keys.Count(handler => handler.IsVisible); + return _entriesByHandler.Keys.Count(handler => handler.IsVisible); } } @@ -50,7 +50,7 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - return _identToHandler.TryGetValue(ident, out var handler) && handler.IsVisible; + return _entriesByIdent.TryGetValue(ident, out var entry) && entry.Handler.IsVisible; } } @@ -64,16 +64,10 @@ public sealed class PairHandlerRegistry : IDisposable IPairHandlerAdapter handler; lock (_gate) { - handler = GetOrAddHandler(registration.CharacterIdent); + var entry = GetOrCreateEntry(registration.CharacterIdent); + handler = entry.Handler; handler.ScheduledForDeletion = false; - - if (!_handlerToPairs.TryGetValue(handler, out var set)) - { - set = new HashSet(); - _handlerToPairs[handler] = set; - } - - set.Add(registration.PairIdent); + entry.AddPair(registration.PairIdent); } ApplyPauseStateForHandler(handler); @@ -109,25 +103,23 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { - if (!_identToHandler.TryGetValue(registration.CharacterIdent, out handler)) + if (!_entriesByIdent.TryGetValue(registration.CharacterIdent, out var entry)) { return PairOperationResult.Fail($"Ident {registration.CharacterIdent} not registered."); } - if (_handlerToPairs.TryGetValue(handler, out var set)) + handler = entry.Handler; + entry.RemovePair(registration.PairIdent); + if (entry.PairCount == 0) { - set.Remove(registration.PairIdent); - if (set.Count == 0) + if (forceDisposal) { - if (forceDisposal) - { - shouldDisposeImmediately = true; - } - else - { - shouldScheduleRemoval = true; - handler.ScheduledForDeletion = true; - } + shouldDisposeImmediately = true; + } + else + { + shouldScheduleRemoval = true; + handler.ScheduledForDeletion = true; } } } @@ -154,13 +146,7 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Fail($"Character data received without ident for {registration.PairIdent.UserId}."); } - IPairHandlerAdapter? handler; - lock (_gate) - { - _identToHandler.TryGetValue(registration.CharacterIdent, out handler); - } - - if (handler is null) + if (!TryGetHandler(registration.CharacterIdent, out var handler) || handler is null) { var registerResult = RegisterOnlinePair(registration); if (!registerResult.Success) @@ -168,30 +154,19 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Fail(registerResult.Error); } - lock (_gate) + if (!TryGetHandler(registration.CharacterIdent, out handler) || handler is null) { - _identToHandler.TryGetValue(registration.CharacterIdent, out handler); + return PairOperationResult.Fail($"Handler not ready for {registration.PairIdent.UserId}."); } } - if (handler is null) - { - return PairOperationResult.Fail($"Handler not ready for {registration.PairIdent.UserId}."); - } - handler.ApplyData(dto.CharaData); return PairOperationResult.Ok(); } public PairOperationResult ApplyLastReceivedData(PairUniqueIdentifier pairIdent, string ident, bool forced = false) { - IPairHandlerAdapter? handler; - lock (_gate) - { - _identToHandler.TryGetValue(ident, out handler); - } - - if (handler is null) + if (!TryGetHandler(ident, out var handler) || handler is null) { return PairOperationResult.Fail($"Cannot reapply data: handler for {pairIdent.UserId} not found."); } @@ -202,13 +177,7 @@ public sealed class PairHandlerRegistry : IDisposable public PairOperationResult SetUploading(PairUniqueIdentifier pairIdent, string ident, bool uploading) { - IPairHandlerAdapter? handler; - lock (_gate) - { - _identToHandler.TryGetValue(ident, out handler); - } - - if (handler is null) + if (!TryGetHandler(ident, out var handler) || handler is null) { return PairOperationResult.Fail($"Cannot set uploading for {pairIdent.UserId}: handler not found."); } @@ -219,44 +188,31 @@ public sealed class PairHandlerRegistry : IDisposable public PairOperationResult SetPausedState(PairUniqueIdentifier pairIdent, string ident, bool paused) { - IPairHandlerAdapter? handler; - lock (_gate) - { - _identToHandler.TryGetValue(ident, out handler); - } - - if (handler is null) + if (!TryGetHandler(ident, out var handler) || handler is null) { return PairOperationResult.Fail($"Cannot update pause state for {pairIdent.UserId}: handler not found."); } _ = paused; // value reflected in pair manager already - // Recalculate pause state against all registered pairs to ensure consistency across contexts. ApplyPauseStateForHandler(handler); return PairOperationResult.Ok(); } public PairOperationResult> GetPairConnections(string ident) { - IPairHandlerAdapter? handler; - HashSet? identifiers = null; - + PairHandlerEntry? entry; lock (_gate) { - _identToHandler.TryGetValue(ident, out handler); - if (handler is not null) - { - _handlerToPairs.TryGetValue(handler, out identifiers); - } + _entriesByIdent.TryGetValue(ident, out entry); } - if (handler is null || identifiers is null) + if (entry is null) { return PairOperationResult>.Fail($"No handler registered for {ident}."); } var list = new List<(PairUniqueIdentifier, PairConnection)>(); - foreach (var pairIdent in identifiers) + foreach (var pairIdent in entry.SnapshotPairs()) { var result = _pairManager.GetPair(pairIdent.UserId); if (result.Success) @@ -279,8 +235,8 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - var success = _identToHandler.TryGetValue(ident, out var resolved); - handler = resolved; + var success = _entriesByIdent.TryGetValue(ident, out var entry); + handler = entry?.Handler; return success; } } @@ -289,7 +245,7 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - return _identToHandler.Values.Distinct().ToList(); + return _entriesByHandler.Keys.ToList(); } } @@ -297,9 +253,9 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - if (_handlerToPairs.TryGetValue(handler, out var pairs)) + if (_entriesByHandler.TryGetValue(handler, out var entry)) { - return pairs.ToList(); + return entry.SnapshotPairs(); } } @@ -330,17 +286,9 @@ public sealed class PairHandlerRegistry : IDisposable List handlers; lock (_gate) { - handlers = _identToHandler.Values.Distinct().ToList(); - _identToHandler.Clear(); - _handlerToPairs.Clear(); - - foreach (var pending in _waitingRequests.Values) - { - pending.Cancel(); - pending.Dispose(); - } - - _waitingRequests.Clear(); + handlers = _entriesByHandler.Keys.ToList(); + _entriesByIdent.Clear(); + _entriesByHandler.Clear(); } foreach (var handler in handlers) @@ -364,14 +312,9 @@ public sealed class PairHandlerRegistry : IDisposable List handlers; lock (_gate) { - handlers = _identToHandler.Values.Distinct().ToList(); - _identToHandler.Clear(); - _handlerToPairs.Clear(); - foreach (var kv in _waitingRequests.Values) - { - kv.Cancel(); - } - _waitingRequests.Clear(); + handlers = _entriesByHandler.Keys.ToList(); + _entriesByIdent.Clear(); + _entriesByHandler.Clear(); } foreach (var handler in handlers) @@ -380,46 +323,23 @@ public sealed class PairHandlerRegistry : IDisposable } } - private IPairHandlerAdapter GetOrAddHandler(string ident) + private PairHandlerEntry GetOrCreateEntry(string ident) { - if (_identToHandler.TryGetValue(ident, out var handler)) + if (_entriesByIdent.TryGetValue(ident, out var entry)) { - return handler; + return entry; } - handler = _handlerFactory.Create(ident); - _identToHandler[ident] = handler; - _handlerToPairs[handler] = new HashSet(); - return handler; - } - - private void EnsureInitialized(IPairHandlerAdapter handler) - { - if (handler.Initialized) - { - return; - } - - try - { - handler.Initialize(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to initialize handler for {Ident}", handler.Ident); - } + var handler = _handlerFactory.Create(ident); + entry = new PairHandlerEntry(ident, handler); + _entriesByIdent[ident] = entry; + _entriesByHandler[handler] = entry; + return entry; } private async Task RemoveAfterGracePeriodAsync(IPairHandlerAdapter handler) { - try - { - await Task.Delay(_deletionGracePeriod).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - return; - } + await Task.Delay(_deletionGracePeriod).ConfigureAwait(false); if (TryFinalizeHandlerRemoval(handler)) { @@ -431,63 +351,15 @@ public sealed class PairHandlerRegistry : IDisposable { lock (_gate) { - if (!_handlerToPairs.TryGetValue(handler, out var set) || set.Count > 0) + if (!_entriesByHandler.TryGetValue(handler, out var entry) || entry.HasPairs) { handler.ScheduledForDeletion = false; return false; } - _handlerToPairs.Remove(handler); - _identToHandler.Remove(handler.Ident); - - if (_waitingRequests.TryGetValue(handler.Ident, out var cts)) - { - cts.Cancel(); - cts.Dispose(); - _waitingRequests.Remove(handler.Ident); - } - + _entriesByHandler.Remove(handler); + _entriesByIdent.Remove(entry.Ident); return true; } } - - private async Task WaitThenApplyDataAsync(PairRegistration registration, OnlineUserCharaDataDto dto, CancellationTokenSource cts) - { - var token = cts.Token; - try - { - while (!token.IsCancellationRequested) - { - IPairHandlerAdapter? handler; - lock (_gate) - { - _identToHandler.TryGetValue(registration.CharacterIdent!, out handler); - } - - if (handler is not null && handler.Initialized) - { - handler.ApplyData(dto.CharaData); - break; - } - - await Task.Delay(TimeSpan.FromMilliseconds(500), token).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - // expected - } - finally - { - lock (_gate) - { - if (_waitingRequests.TryGetValue(registration.CharacterIdent!, out var existing) && existing == cts) - { - _waitingRequests.Remove(registration.CharacterIdent!); - } - } - - cts.Dispose(); - } - } } diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index 1e0e359..1593a7c 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -12,6 +12,9 @@ using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; +/// +/// keeps pair info for ui and reapplication +/// public sealed class PairLedger : DisposableMediatorSubscriberBase { private readonly PairManager _pairManager; diff --git a/LightlessSync/PlayerData/Pairs/PairManager.cs b/LightlessSync/PlayerData/Pairs/PairManager.cs index adbe5b8..95525b3 100644 --- a/LightlessSync/PlayerData/Pairs/PairManager.cs +++ b/LightlessSync/PlayerData/Pairs/PairManager.cs @@ -9,6 +9,9 @@ using LightlessSync.API.Dto.User; namespace LightlessSync.PlayerData.Pairs; +/// +/// in memory state for pairs, groups, and syncshells +/// public sealed class PairManager { private readonly object _gate = new(); diff --git a/LightlessSync/PlayerData/Pairs/PairState.cs b/LightlessSync/PlayerData/Pairs/PairModels.cs similarity index 69% rename from LightlessSync/PlayerData/Pairs/PairState.cs rename to LightlessSync/PlayerData/Pairs/PairModels.cs index 0e2a508..015a2a8 100644 --- a/LightlessSync/PlayerData/Pairs/PairState.cs +++ b/LightlessSync/PlayerData/Pairs/PairModels.cs @@ -7,42 +7,27 @@ using LightlessSync.API.Dto.Group; namespace LightlessSync.PlayerData.Pairs; -public readonly struct PairOperationResult +/// +/// core models for the pair system +/// +public sealed class PairState { - private PairOperationResult(bool success, string? error) - { - Success = success; - Error = error; - } + public CharacterData? CharacterData { get; set; } + public Guid? TemporaryCollectionId { get; set; } - public bool Success { get; } - public string? Error { get; } - - public static PairOperationResult Ok() => new(true, null); - - public static PairOperationResult Fail(string error) => new(false, error); + public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty); } -public readonly struct PairOperationResult -{ - private PairOperationResult(bool success, T value, string? error) - { - Success = success; - Value = value; - Error = error; - } - - public bool Success { get; } - public T Value { get; } - public string? Error { get; } - - public static PairOperationResult Ok(T value) => new(true, value, null); - - public static PairOperationResult Fail(string error) => new(false, default!, error); -} +public readonly record struct PairUniqueIdentifier(string UserId); +/// +/// link between a pair id and character ident +/// public sealed record PairRegistration(PairUniqueIdentifier PairIdent, string? CharacterIdent); +/// +/// per group membership info for a pair +/// public sealed class GroupPairRelationship { public GroupPairRelationship(string groupId, GroupPairUserInfo? info) @@ -60,6 +45,9 @@ public sealed class GroupPairRelationship } } +/// +/// runtime view of a single pair connection +/// public sealed class PairConnection { public PairConnection(UserData user) @@ -121,6 +109,9 @@ public sealed class PairConnection } } +/// +/// syncshell metadata plus member connections +/// public sealed class Syncshell { public Syncshell(GroupFullInfoDto dto) @@ -138,12 +129,94 @@ public sealed class Syncshell } } -public sealed class PairState +/// +/// simple success/failure result +/// +public readonly struct PairOperationResult { - public CharacterData? CharacterData { get; set; } - public Guid? TemporaryCollectionId { get; set; } + private PairOperationResult(bool success, string? error) + { + Success = success; + Error = error; + } - public bool IsEmpty => CharacterData is null && (TemporaryCollectionId is null || TemporaryCollectionId == Guid.Empty); + public bool Success { get; } + public string? Error { get; } + + public static PairOperationResult Ok() => new(true, null); + + public static PairOperationResult Fail(string error) => new(false, error); } -public readonly record struct PairUniqueIdentifier(string UserId); +/// +/// typed success/failure result +/// +public readonly struct PairOperationResult +{ + private PairOperationResult(bool success, T value, string? error) + { + Success = success; + Value = value; + Error = error; + } + + public bool Success { get; } + public T Value { get; } + public string? Error { get; } + + public static PairOperationResult Ok(T value) => new(true, value, null); + + public static PairOperationResult Fail(string error) => new(false, default!, error); +} + +/// +/// state of which optional plugin warnings were shown +/// +public record OptionalPluginWarning +{ + public bool ShownHeelsWarning { get; set; } = false; + public bool ShownCustomizePlusWarning { get; set; } = false; + public bool ShownHonorificWarning { get; set; } = false; + public bool ShownMoodlesWarning { get; set; } = false; + public bool ShowPetNicknamesWarning { get; set; } = false; +} + +/// +/// tracks the handler registered pairs for an ident +/// +internal sealed class PairHandlerEntry +{ + private readonly HashSet _pairs = new(); + + public PairHandlerEntry(string ident, IPairHandlerAdapter handler) + { + Ident = ident; + Handler = handler; + } + + public string Ident { get; } + public IPairHandlerAdapter Handler { get; } + + public bool HasPairs => _pairs.Count > 0; + public int PairCount => _pairs.Count; + + public void AddPair(PairUniqueIdentifier pair) + { + _pairs.Add(pair); + } + + public bool RemovePair(PairUniqueIdentifier pair) + { + return _pairs.Remove(pair); + } + + public IReadOnlyCollection SnapshotPairs() + { + if (_pairs.Count == 0) + { + return Array.Empty(); + } + + return _pairs.ToArray(); + } +} diff --git a/LightlessSync/PlayerData/Pairs/PairStateCache.cs b/LightlessSync/PlayerData/Pairs/PairStateCache.cs index 67e8c8c..b6a7872 100644 --- a/LightlessSync/PlayerData/Pairs/PairStateCache.cs +++ b/LightlessSync/PlayerData/Pairs/PairStateCache.cs @@ -6,6 +6,9 @@ using LightlessSync.Utils; namespace LightlessSync.PlayerData.Pairs; +/// +/// cache for character/pair data and penumbra collections +/// public sealed class PairStateCache { private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index 1840813..805bc26 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -14,6 +14,9 @@ using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; +/// +/// pushes character data to visible pairs +/// public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase { private readonly ApiController _apiController; -- 2.49.1 From 8cc83bce798347a588158fbdac049ea2ba470de3 Mon Sep 17 00:00:00 2001 From: azyges Date: Thu, 27 Nov 2025 00:29:56 +0900 Subject: [PATCH 050/140] big clean up in progress 1 --- .../Interop/Ipc/IpcCallerPenumbra.cs | 5 - .../PlayerData/Factories/PairFactory.cs | 5 +- LightlessSync/PlayerData/Pairs/Pair.cs | 3 - .../PlayerData/Pairs/PairCoordinator.Users.cs | 1 - .../PlayerData/Pairs/PairCoordinator.cs | 2 - .../PlayerData/Pairs/PairHandlerAdapter.cs | 6 - .../PlayerData/Pairs/PairHandlerRegistry.cs | 6 - LightlessSync/PlayerData/Pairs/PairLedger.cs | 7 - LightlessSync/PlayerData/Pairs/PairManager.cs | 3 - LightlessSync/PlayerData/Pairs/PairModels.cs | 2 - .../PlayerData/Pairs/PairStateCache.cs | 2 - .../Pairs/VisibleUserDataDistributor.cs | 5 - LightlessSync/Services/Chat/ChatModels.cs | 2 - .../Services/Chat/ZoneChatService.cs | 7 - .../TextureCompression/IndexDownscaler.cs | 312 ++++++++++++++++++ .../TextureCompression/TexFileHelper.cs | 2 - .../TextureCompressionCapabilities.cs | 3 - .../TextureCompressionRequest.cs | 1 - .../TextureCompressionService.cs | 5 - .../TextureDownscaleService.cs | 305 +---------------- .../TextureCompression/TextureMapKind.cs | 2 - .../TextureMetadataHelper.cs | 31 +- .../TextureUsageCategory.cs | 2 +- LightlessSync/UI/BroadcastUI.cs | 1 - LightlessSync/UI/CompactUI.cs | 4 - LightlessSync/UI/DataAnalysisUi.cs | 76 +++-- LightlessSync/UI/ProfileTags.cs | 33 -- LightlessSync/UI/SettingsUi.cs | 7 - LightlessSync/UI/StandaloneProfileUi.cs | 4 - LightlessSync/UI/SyncshellAdminUI.cs | 5 - LightlessSync/UI/SyncshellFinderUI.cs | 3 - LightlessSync/UI/Tags/ProfileTagRenderer.cs | 2 - LightlessSync/UI/Tags/ProfileTagService.cs | 50 ++- .../WebAPI/Files/FileDownloadManager.cs | 5 - .../WebAPI/Files/FileTransferOrchestrator.cs | 1 - .../WebAPI/Files/FileUploadManager.cs | 1 - 36 files changed, 389 insertions(+), 522 deletions(-) create mode 100644 LightlessSync/Services/TextureCompression/IndexDownscaler.cs delete mode 100644 LightlessSync/UI/ProfileTags.cs diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index 2ecc56b..4135642 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -9,11 +9,6 @@ using Penumbra.Api.Enums; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace LightlessSync.Interop.Ipc; diff --git a/LightlessSync/PlayerData/Factories/PairFactory.cs b/LightlessSync/PlayerData/Factories/PairFactory.cs index fd63f51..a7ffd6e 100644 --- a/LightlessSync/PlayerData/Factories/PairFactory.cs +++ b/LightlessSync/PlayerData/Factories/PairFactory.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using LightlessSync.API.Data.Enum; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto.User; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 87933ac..a861dae 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -1,12 +1,9 @@ -using System; -using System.Linq; using Dalamud.Game.Gui.ContextMenu; using Dalamud.Game.Text.SeStringHandling; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.User; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs index 5925663..0891035 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.Users.cs @@ -1,5 +1,4 @@ using LightlessSync.API.Data; -using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.User; using LightlessSync.Services.Events; diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs index b7af0cd..7774851 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs @@ -1,9 +1,7 @@ -using System; using System.Collections.Concurrent; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 6950186..d08cc19 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1,11 +1,5 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index 48d3c9e..97e3733 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; -using LightlessSync.API.Dto.CharaData; using LightlessSync.API.Dto.User; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index 1593a7c..66decfb 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -1,11 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; -using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.UI.Models; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/PlayerData/Pairs/PairManager.cs b/LightlessSync/PlayerData/Pairs/PairManager.cs index 95525b3..fc6844a 100644 --- a/LightlessSync/PlayerData/Pairs/PairManager.cs +++ b/LightlessSync/PlayerData/Pairs/PairManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Dto.Group; diff --git a/LightlessSync/PlayerData/Pairs/PairModels.cs b/LightlessSync/PlayerData/Pairs/PairModels.cs index 015a2a8..9f34ab2 100644 --- a/LightlessSync/PlayerData/Pairs/PairModels.cs +++ b/LightlessSync/PlayerData/Pairs/PairModels.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; diff --git a/LightlessSync/PlayerData/Pairs/PairStateCache.cs b/LightlessSync/PlayerData/Pairs/PairStateCache.cs index b6a7872..3d7a377 100644 --- a/LightlessSync/PlayerData/Pairs/PairStateCache.cs +++ b/LightlessSync/PlayerData/Pairs/PairStateCache.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using LightlessSync.API.Data; using LightlessSync.Utils; diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index 805bc26..a1c7587 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; using LightlessSync.Services; diff --git a/LightlessSync/Services/Chat/ChatModels.cs b/LightlessSync/Services/Chat/ChatModels.cs index f83a7e9..e9058e7 100644 --- a/LightlessSync/Services/Chat/ChatModels.cs +++ b/LightlessSync/Services/Chat/ChatModels.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using LightlessSync.API.Dto.Chat; namespace LightlessSync.Services.Chat; diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 1aee611..4499cf8 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -1,12 +1,5 @@ -using LightlessSync; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Chat; -using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.WebAPI; diff --git a/LightlessSync/Services/TextureCompression/IndexDownscaler.cs b/LightlessSync/Services/TextureCompression/IndexDownscaler.cs new file mode 100644 index 0000000..615a5e2 --- /dev/null +++ b/LightlessSync/Services/TextureCompression/IndexDownscaler.cs @@ -0,0 +1,312 @@ +using System.Numerics; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +/* + * Index upscaler code (converted/reversed for downscaling purposes) provided by Ny + * thank you!! +*/ + +namespace LightlessSync.Services.TextureCompression; + +internal static class IndexDownscaler +{ + private static readonly Vector2[] SampleOffsets = + { + new(0.25f, 0.25f), + new(0.75f, 0.25f), + new(0.25f, 0.75f), + new(0.75f, 0.75f), + }; + + public static Image Downscale(Image source, int targetWidth, int targetHeight, int blockMultiple) + { + var current = source.Clone(); + + while (current.Width > targetWidth || current.Height > targetHeight) + { + var nextWidth = Math.Max(targetWidth, Math.Max(blockMultiple, current.Width / 2)); + var nextHeight = Math.Max(targetHeight, Math.Max(blockMultiple, current.Height / 2)); + var next = new Image(nextWidth, nextHeight); + + for (var y = 0; y < nextHeight; y++) + { + var srcY = Math.Min(current.Height - 1, y * 2); + for (var x = 0; x < nextWidth; x++) + { + var srcX = Math.Min(current.Width - 1, x * 2); + + var topLeft = current[srcX, srcY]; + var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY]; + var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)]; + var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)]; + + next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight); + } + } + + current.Dispose(); + current = next; + } + + return current; + } + + private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight) + { + Span ordered = stackalloc Rgba32[4] + { + bottomLeft, + bottomRight, + topRight, + topLeft + }; + + Span weights = stackalloc float[4]; + var hasContribution = false; + + foreach (var sample in SampleOffsets) + { + if (TryAccumulateSampleWeights(ordered, sample, weights)) + { + hasContribution = true; + } + } + + if (hasContribution) + { + var bestIndex = IndexOfMax(weights); + if (bestIndex >= 0 && weights[bestIndex] > 0f) + { + return ordered[bestIndex]; + } + } + + Span fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight }; + return PickMajorityColor(fallback); + } + + private static bool TryAccumulateSampleWeights(ReadOnlySpan colors, in Vector2 sampleUv, Span weights) + { + var red = new Vector4( + colors[0].R / 255f, + colors[1].R / 255f, + colors[2].R / 255f, + colors[3].R / 255f); + + var symbols = QuantizeSymbols(red); + var cellUv = ComputeShiftedUv(sampleUv); + + Span order = stackalloc int[4]; + order[0] = 0; + order[1] = 1; + order[2] = 2; + order[3] = 3; + + ApplySymmetry(ref symbols, ref cellUv, order); + + var equality = BuildEquality(symbols, symbols.W); + var selector = BuildSelector(equality, symbols, cellUv); + + const uint lut = 0x00000C07u; + + if (((lut >> (int)selector) & 1u) != 0u) + { + weights[order[3]] += 1f; + return true; + } + + if (selector == 3u) + { + equality = BuildEquality(symbols, symbols.Z); + } + + var weight = ComputeWeight(equality, cellUv); + if (weight <= 1e-6f) + { + return false; + } + + var factor = 1f / weight; + + var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor; + var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor; + var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor; + var wY = equality.Y * cellUv.X * cellUv.Y * factor; + + var contributed = false; + + if (wW > 0f) + { + weights[order[3]] += wW; + contributed = true; + } + + if (wX > 0f) + { + weights[order[0]] += wX; + contributed = true; + } + + if (wZ > 0f) + { + weights[order[2]] += wZ; + contributed = true; + } + + if (wY > 0f) + { + weights[order[1]] += wY; + contributed = true; + } + + return contributed; + } + + private static Vector4 QuantizeSymbols(in Vector4 channel) + => new( + Quantize(channel.X), + Quantize(channel.Y), + Quantize(channel.Z), + Quantize(channel.W)); + + private static float Quantize(float value) + { + var clamped = Math.Clamp(value, 0f, 1f); + return (MathF.Round(clamped * 16f) + 0.5f) / 16f; + } + + private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span order) + { + if (cellUv.X >= 0.5f) + { + symbols = SwapYxwz(symbols, order); + cellUv.X = 1f - cellUv.X; + } + + if (cellUv.Y >= 0.5f) + { + symbols = SwapWzyx(symbols, order); + cellUv.Y = 1f - cellUv.Y; + } + } + + private static Vector4 BuildEquality(in Vector4 symbols, float reference) + => new( + AreEqual(symbols.X, reference) ? 1f : 0f, + AreEqual(symbols.Y, reference) ? 1f : 0f, + AreEqual(symbols.Z, reference) ? 1f : 0f, + AreEqual(symbols.W, reference) ? 1f : 0f); + + private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv) + { + uint selector = 0; + if (equality.X > 0.5f) selector |= 4u; + if (equality.Y > 0.5f) selector |= 8u; + if (equality.Z > 0.5f) selector |= 16u; + if (AreEqual(symbols.X, symbols.Z)) selector |= 2u; + if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u; + + return selector; + } + + private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv) + => equality.W * (1f - cellUv.X) * (1f - cellUv.Y) + + equality.X * (1f - cellUv.X) * cellUv.Y + + equality.Z * cellUv.X * (1f - cellUv.Y) + + equality.Y * cellUv.X * cellUv.Y; + + private static Vector2 ComputeShiftedUv(in Vector2 uv) + { + var shifted = new Vector2( + uv.X - MathF.Floor(uv.X), + uv.Y - MathF.Floor(uv.Y)); + + shifted.X -= 0.5f; + if (shifted.X < 0f) + { + shifted.X += 1f; + } + + shifted.Y -= 0.5f; + if (shifted.Y < 0f) + { + shifted.Y += 1f; + } + + return shifted; + } + + private static Vector4 SwapYxwz(in Vector4 v, Span order) + { + var o0 = order[0]; + var o1 = order[1]; + var o2 = order[2]; + var o3 = order[3]; + + order[0] = o1; + order[1] = o0; + order[2] = o3; + order[3] = o2; + + return new Vector4(v.Y, v.X, v.W, v.Z); + } + + private static Vector4 SwapWzyx(in Vector4 v, Span order) + { + var o0 = order[0]; + var o1 = order[1]; + var o2 = order[2]; + var o3 = order[3]; + + order[0] = o3; + order[1] = o2; + order[2] = o1; + order[3] = o0; + + return new Vector4(v.W, v.Z, v.Y, v.X); + } + + private static int IndexOfMax(ReadOnlySpan values) + { + var bestIndex = -1; + var bestValue = 0f; + + for (var i = 0; i < values.Length; i++) + { + if (values[i] > bestValue) + { + bestValue = values[i]; + bestIndex = i; + } + } + + return bestIndex; + } + + private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f; + + private static Rgba32 PickMajorityColor(ReadOnlySpan colors) + { + var counts = new Dictionary(colors.Length); + foreach (var color in colors) + { + if (counts.TryGetValue(color, out var count)) + { + counts[color] = count + 1; + } + else + { + counts[color] = 1; + } + } + + return counts + .OrderByDescending(kvp => kvp.Value) + .ThenByDescending(kvp => kvp.Key.A) + .ThenByDescending(kvp => kvp.Key.R) + .ThenByDescending(kvp => kvp.Key.G) + .ThenByDescending(kvp => kvp.Key.B) + .First().Key; + } +} \ No newline at end of file diff --git a/LightlessSync/Services/TextureCompression/TexFileHelper.cs b/LightlessSync/Services/TextureCompression/TexFileHelper.cs index b5e2ab8..7258fbf 100644 --- a/LightlessSync/Services/TextureCompression/TexFileHelper.cs +++ b/LightlessSync/Services/TextureCompression/TexFileHelper.cs @@ -1,5 +1,3 @@ -using System; -using System.IO; using System.Runtime.InteropServices; using Lumina.Data.Files; using OtterTex; diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs index 81e10c5..8725d07 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.IO; -using System.Linq; using Penumbra.Api.Enums; namespace LightlessSync.Services.TextureCompression; diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs b/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs index 0877d55..18681d8 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionRequest.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; namespace LightlessSync.Services.TextureCompression; diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs index 2d4a1d2..c31539f 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.Interop.Ipc; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index e5ead9d..b43b1b5 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Concurrent; using System.Buffers.Binary; using System.Globalization; -using System.Numerics; using System.IO; using OtterTex; using OtterImage = OtterTex.Image; @@ -15,7 +14,6 @@ using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; /* - * Index upscaler code (converted/reversed for downscaling purposes) provided by Ny * OtterTex made by Ottermandias * thank you!! */ @@ -183,7 +181,7 @@ public sealed class TextureDownscaleService return; } - using var resized = ReduceIndexTexture(originalImage, targetSize.width, targetSize.height); + using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple); var resizedPixels = new byte[targetSize.width * targetSize.height * 4]; resized.CopyPixelDataTo(resizedPixels); @@ -231,8 +229,7 @@ public sealed class TextureDownscaleService private static bool IsIndexMap(TextureMapKind kind) => kind is TextureMapKind.Mask - or TextureMapKind.Index - or TextureMapKind.Ui; + or TextureMapKind.Index; private Task TryDropTopMipAsync( string hash, @@ -423,39 +420,6 @@ public sealed class TextureDownscaleService private static int ReduceDimension(int value) => value <= 1 ? 1 : Math.Max(1, value / 2); - private static Image ReduceIndexTexture(Image source, int targetWidth, int targetHeight) - { - var current = source.Clone(); - - while (current.Width > targetWidth || current.Height > targetHeight) - { - var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, current.Width / 2)); - var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, current.Height / 2)); - var next = new Image(nextWidth, nextHeight); - - for (int y = 0; y < nextHeight; y++) - { - var srcY = Math.Min(current.Height - 1, y * 2); - for (int x = 0; x < nextWidth; x++) - { - var srcX = Math.Min(current.Width - 1, x * 2); - - var topLeft = current[srcX, srcY]; - var topRight = current[Math.Min(current.Width - 1, srcX + 1), srcY]; - var bottomLeft = current[srcX, Math.Min(current.Height - 1, srcY + 1)]; - var bottomRight = current[Math.Min(current.Width - 1, srcX + 1), Math.Min(current.Height - 1, srcY + 1)]; - - next[x, y] = DownscaleIndexBlock(topLeft, topRight, bottomLeft, bottomRight); - } - } - - current.Dispose(); - current = next; - } - - return current; - } - private static Image ReduceLinearTexture(Image source, int targetWidth, int targetHeight) { var clone = source.Clone(); @@ -470,271 +434,6 @@ public sealed class TextureDownscaleService return clone; } - private static Rgba32 DownscaleIndexBlock(in Rgba32 topLeft, in Rgba32 topRight, in Rgba32 bottomLeft, in Rgba32 bottomRight) - { - Span ordered = stackalloc Rgba32[4] - { - bottomLeft, - bottomRight, - topRight, - topLeft - }; - - Span weights = stackalloc float[4]; - var hasContribution = false; - - foreach (var sample in SampleOffsets) - { - if (TryAccumulateSampleWeights(ordered, sample, weights)) - { - hasContribution = true; - } - } - - if (hasContribution) - { - var bestIndex = IndexOfMax(weights); - if (bestIndex >= 0 && weights[bestIndex] > 0f) - { - return ordered[bestIndex]; - } - } - - Span fallback = stackalloc Rgba32[4] { topLeft, topRight, bottomLeft, bottomRight }; - return PickMajorityColor(fallback); - } - - private static readonly Vector2[] SampleOffsets = - { - new(0.25f, 0.25f), - new(0.75f, 0.25f), - new(0.25f, 0.75f), - new(0.75f, 0.75f), - }; - - private static bool TryAccumulateSampleWeights(ReadOnlySpan colors, in Vector2 sampleUv, Span weights) - { - var red = new Vector4( - colors[0].R / 255f, - colors[1].R / 255f, - colors[2].R / 255f, - colors[3].R / 255f); - - var symbols = QuantizeSymbols(red); - var cellUv = ComputeShiftedUv(sampleUv); - - Span order = stackalloc int[4]; - order[0] = 0; - order[1] = 1; - order[2] = 2; - order[3] = 3; - - ApplySymmetry(ref symbols, ref cellUv, order); - - var equality = BuildEquality(symbols, symbols.W); - var selector = BuildSelector(equality, symbols, cellUv); - - const uint lut = 0x00000C07u; - - if (((lut >> (int)selector) & 1u) != 0u) - { - weights[order[3]] += 1f; - return true; - } - - if (selector == 3u) - { - equality = BuildEquality(symbols, symbols.Z); - } - - var weight = ComputeWeight(equality, cellUv); - if (weight <= 1e-6f) - { - return false; - } - - var factor = 1f / weight; - - var wW = equality.W * (1f - cellUv.X) * (1f - cellUv.Y) * factor; - var wX = equality.X * (1f - cellUv.X) * cellUv.Y * factor; - var wZ = equality.Z * cellUv.X * (1f - cellUv.Y) * factor; - var wY = equality.Y * cellUv.X * cellUv.Y * factor; - - var contributed = false; - - if (wW > 0f) - { - weights[order[3]] += wW; - contributed = true; - } - - if (wX > 0f) - { - weights[order[0]] += wX; - contributed = true; - } - - if (wZ > 0f) - { - weights[order[2]] += wZ; - contributed = true; - } - - if (wY > 0f) - { - weights[order[1]] += wY; - contributed = true; - } - - return contributed; - } - - private static Vector4 QuantizeSymbols(in Vector4 channel) - => new( - Quantize(channel.X), - Quantize(channel.Y), - Quantize(channel.Z), - Quantize(channel.W)); - - private static float Quantize(float value) - { - var clamped = Math.Clamp(value, 0f, 1f); - return (MathF.Round(clamped * 16f) + 0.5f) / 16f; - } - - private static void ApplySymmetry(ref Vector4 symbols, ref Vector2 cellUv, Span order) - { - if (cellUv.X >= 0.5f) - { - symbols = SwapYxwz(symbols, order); - cellUv.X = 1f - cellUv.X; - } - - if (cellUv.Y >= 0.5f) - { - symbols = SwapWzyx(symbols, order); - cellUv.Y = 1f - cellUv.Y; - } - } - - private static Vector4 BuildEquality(in Vector4 symbols, float reference) - => new( - AreEqual(symbols.X, reference) ? 1f : 0f, - AreEqual(symbols.Y, reference) ? 1f : 0f, - AreEqual(symbols.Z, reference) ? 1f : 0f, - AreEqual(symbols.W, reference) ? 1f : 0f); - - private static uint BuildSelector(in Vector4 equality, in Vector4 symbols, in Vector2 cellUv) - { - uint selector = 0; - if (equality.X > 0.5f) selector |= 4u; - if (equality.Y > 0.5f) selector |= 8u; - if (equality.Z > 0.5f) selector |= 16u; - if (AreEqual(symbols.X, symbols.Z)) selector |= 2u; - if (cellUv.X + cellUv.Y >= 0.5f) selector |= 1u; - - return selector; - } - - private static float ComputeWeight(in Vector4 equality, in Vector2 cellUv) - => equality.W * (1f - cellUv.X) * (1f - cellUv.Y) - + equality.X * (1f - cellUv.X) * cellUv.Y - + equality.Z * cellUv.X * (1f - cellUv.Y) - + equality.Y * cellUv.X * cellUv.Y; - - private static Vector2 ComputeShiftedUv(in Vector2 uv) - { - var shifted = new Vector2( - uv.X - MathF.Floor(uv.X), - uv.Y - MathF.Floor(uv.Y)); - - shifted.X -= 0.5f; - if (shifted.X < 0f) - { - shifted.X += 1f; - } - - shifted.Y -= 0.5f; - if (shifted.Y < 0f) - { - shifted.Y += 1f; - } - - return shifted; - } - - private static Vector4 SwapYxwz(in Vector4 v, Span order) - { - var o0 = order[0]; - var o1 = order[1]; - var o2 = order[2]; - var o3 = order[3]; - - order[0] = o1; - order[1] = o0; - order[2] = o3; - order[3] = o2; - - return new Vector4(v.Y, v.X, v.W, v.Z); - } - - private static Vector4 SwapWzyx(in Vector4 v, Span order) - { - var o0 = order[0]; - var o1 = order[1]; - var o2 = order[2]; - var o3 = order[3]; - - order[0] = o3; - order[1] = o2; - order[2] = o1; - order[3] = o0; - - return new Vector4(v.W, v.Z, v.Y, v.X); - } - - private static int IndexOfMax(ReadOnlySpan values) - { - var bestIndex = -1; - var bestValue = 0f; - - for (var i = 0; i < values.Length; i++) - { - if (values[i] > bestValue) - { - bestValue = values[i]; - bestIndex = i; - } - } - - return bestIndex; - } - - private static bool AreEqual(float a, float b) => MathF.Abs(a - b) <= 1e-5f; - - private static Rgba32 PickMajorityColor(ReadOnlySpan colors) - { - var counts = new Dictionary(colors.Length); - foreach (var color in colors) - { - if (counts.TryGetValue(color, out var count)) - { - counts[color] = count + 1; - } - else - { - counts[color] = 1; - } - } - - return counts - .OrderByDescending(kvp => kvp.Value) - .ThenByDescending(kvp => kvp.Key.A) - .ThenByDescending(kvp => kvp.Key.R) - .ThenByDescending(kvp => kvp.Key.G) - .ThenByDescending(kvp => kvp.Key.B) - .First().Key; - } private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension) { diff --git a/LightlessSync/Services/TextureCompression/TextureMapKind.cs b/LightlessSync/Services/TextureCompression/TextureMapKind.cs index ed2dee1..b007613 100644 --- a/LightlessSync/Services/TextureCompression/TextureMapKind.cs +++ b/LightlessSync/Services/TextureCompression/TextureMapKind.cs @@ -7,7 +7,5 @@ public enum TextureMapKind Specular, Mask, Index, - Emissive, - Ui, Unknown } diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs index 3c0934c..010f9be 100644 --- a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using Dalamud.Plugin.Services; using Microsoft.Extensions.Logging; -using Penumbra.Api.Enums; using Penumbra.GameData.Files; namespace LightlessSync.Services.TextureCompression; @@ -37,9 +32,9 @@ public sealed class TextureMetadataHelper private static readonly (TextureUsageCategory Category, string Token)[] CategoryTokens = { - (TextureUsageCategory.Ui, "/ui/"), - (TextureUsageCategory.Ui, "/uld/"), - (TextureUsageCategory.Ui, "/icon/"), + (TextureUsageCategory.UI, "/ui/"), + (TextureUsageCategory.UI, "/uld/"), + (TextureUsageCategory.UI, "/icon/"), (TextureUsageCategory.VisualEffect, "/vfx/"), @@ -104,9 +99,6 @@ public sealed class TextureMetadataHelper (TextureMapKind.Specular, "_s"), (TextureMapKind.Specular, "_spec"), - (TextureMapKind.Emissive, "_em"), - (TextureMapKind.Emissive, "_glow"), - (TextureMapKind.Index, "_id"), (TextureMapKind.Index, "_idx"), (TextureMapKind.Index, "_index"), @@ -133,10 +125,10 @@ public sealed class TextureMetadataHelper _dataManager = dataManager; } - public bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info) + public static bool TryGetRecommendationInfo(TextureCompressionTarget target, out (string Title, string Description) info) => RecommendationCatalog.TryGetValue(target, out info); - public TextureUsageCategory DetermineCategory(string? gamePath) + public static TextureUsageCategory DetermineCategory(string? gamePath) { var normalized = Normalize(gamePath); if (string.IsNullOrEmpty(normalized)) @@ -193,7 +185,7 @@ public sealed class TextureMetadataHelper return TextureUsageCategory.Unknown; } - public string DetermineSlot(TextureUsageCategory category, string? gamePath) + public static string DetermineSlot(TextureUsageCategory category, string? gamePath) { if (category == TextureUsageCategory.Customization) return GuessCustomizationSlot(gamePath); @@ -218,7 +210,7 @@ public sealed class TextureMetadataHelper TextureUsageCategory.Companion => "Companion", TextureUsageCategory.VisualEffect => "VFX", TextureUsageCategory.Housing => "Housing", - TextureUsageCategory.Ui => "UI", + TextureUsageCategory.UI => "UI", _ => "General" }; } @@ -260,7 +252,7 @@ public sealed class TextureMetadataHelper return false; } - private void AddGameMaterialCandidates(string? gamePath, IList candidates) + private static void AddGameMaterialCandidates(string? gamePath, IList candidates) { var normalized = Normalize(gamePath); if (string.IsNullOrEmpty(normalized)) @@ -286,7 +278,7 @@ public sealed class TextureMetadataHelper } } - private void AddLocalMaterialCandidates(string? localTexturePath, IList candidates) + private static void AddLocalMaterialCandidates(string? localTexturePath, IList candidates) { if (string.IsNullOrEmpty(localTexturePath)) return; @@ -397,7 +389,7 @@ public sealed class TextureMetadataHelper return TextureMapKind.Unknown; } - public bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target) + public static bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target) { var normalized = (format ?? string.Empty).ToUpperInvariant(); if (normalized.Contains("BC1", StringComparison.Ordinal)) @@ -434,7 +426,7 @@ public sealed class TextureMetadataHelper return false; } - public (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind) + public static (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind) { TextureCompressionTarget? current = null; if (TryMapFormatToTarget(format, out var mapped)) @@ -446,7 +438,6 @@ public sealed class TextureMetadataHelper TextureMapKind.Mask => TextureCompressionTarget.BC4, TextureMapKind.Index => TextureCompressionTarget.BC3, TextureMapKind.Specular => TextureCompressionTarget.BC4, - TextureMapKind.Emissive => TextureCompressionTarget.BC3, TextureMapKind.Diffuse => TextureCompressionTarget.BC7, _ => TextureCompressionTarget.BC7 }; diff --git a/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs b/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs index c4af7b7..ac01393 100644 --- a/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs +++ b/LightlessSync/Services/TextureCompression/TextureUsageCategory.cs @@ -10,7 +10,7 @@ public enum TextureUsageCategory Companion, Monster, Housing, - Ui, + UI, VisualEffect, Unknown } diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs index c008f31..1f4eb37 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/BroadcastUI.cs @@ -2,7 +2,6 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; -using Dalamud.Utility; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 40b0f0e..8adb54a 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -2,8 +2,6 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; -using Dalamud.Utility; -using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.Interop.Ipc; @@ -24,11 +22,9 @@ using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Logging; -using System; using System.Collections.Concurrent; using System.Collections.Immutable; using System.Globalization; -using System.Linq; using System.Numerics; using System.Reflection; diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 725e004..932653d 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -12,16 +12,14 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; using LightlessSync.Utils; -using Penumbra.Api.Enums; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; +using OtterTex; using System.Globalization; -using System.IO; -using System.Linq; using System.Numerics; -using System.Threading; -using System.Threading.Tasks; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using ImageSharpImage = SixLabors.ImageSharp.Image; namespace LightlessSync.UI; @@ -810,11 +808,11 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var primaryGamePath = entry.GamePaths.FirstOrDefault() ?? string.Empty; var classificationPath = string.IsNullOrEmpty(primaryGamePath) ? primaryFile : primaryGamePath; var mapKind = _textureMetadataHelper.DetermineMapKind(primaryGamePath, primaryFile); - var category = _textureMetadataHelper.DetermineCategory(classificationPath); - var slot = _textureMetadataHelper.DetermineSlot(category, classificationPath); + var category = TextureMetadataHelper.DetermineCategory(classificationPath); + var slot = TextureMetadataHelper.DetermineSlot(category, classificationPath); var format = entry.Format.Value; - var suggestion = _textureMetadataHelper.GetSuggestedTarget(format, mapKind); - TextureCompressionTarget? currentTarget = _textureMetadataHelper.TryMapFormatToTarget(format, out var mappedTarget) + var suggestion = TextureMetadataHelper.GetSuggestedTarget(format, mapKind); + TextureCompressionTarget? currentTarget = TextureMetadataHelper.TryMapFormatToTarget(format, out var mappedTarget) ? mappedTarget : null; var displayName = Path.GetFileName(primaryFile); @@ -2014,23 +2012,43 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private async Task BuildPreviewAsync(TextureRow row, CancellationToken token) { - if (!_ipcManager.Penumbra.APIAvailable) + const int PreviewMaxDimension = 1024; + + token.ThrowIfCancellationRequested(); + + if (!File.Exists(row.PrimaryFilePath)) { return null; } - var tempFile = Path.Combine(Path.GetTempPath(), $"lightless_preview_{Guid.NewGuid():N}.png"); try { - var job = new TextureConversionJob(row.PrimaryFilePath, tempFile, TextureType.Png, IncludeMipMaps: false); - await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { job }, null, token).ConfigureAwait(false); - if (!File.Exists(tempFile)) + using var scratch = TexFileHelper.Load(row.PrimaryFilePath); + using var rgbaScratch = scratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); + + var meta = rgbaInfo.Meta; + var width = meta.Width; + var height = meta.Height; + var bytesPerPixel = meta.Format.BitsPerPixel() / 8; + var requiredLength = width * height * bytesPerPixel; + + token.ThrowIfCancellationRequested(); + + var rgbaPixels = rgbaScratch.Pixels[..requiredLength].ToArray(); + using var image = ImageSharpImage.LoadPixelData(rgbaPixels, width, height); + + if (Math.Max(width, height) > PreviewMaxDimension) { - return null; + var dominant = Math.Max(width, height); + var scale = PreviewMaxDimension / (float)dominant; + var targetWidth = Math.Max(1, (int)MathF.Round(width * scale)); + var targetHeight = Math.Max(1, (int)MathF.Round(height * scale)); + image.Mutate(ctx => ctx.Resize(targetWidth, targetHeight, KnownResamplers.Lanczos3)); } - var data = await File.ReadAllBytesAsync(tempFile, token).ConfigureAwait(false); - return _uiSharedService.LoadImage(data); + using var ms = new MemoryStream(); + await image.SaveAsPngAsync(ms, cancellationToken: token).ConfigureAwait(false); + return _uiSharedService.LoadImage(ms.ToArray()); } catch (OperationCanceledException) { @@ -2041,20 +2059,6 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _logger.LogDebug(ex, "Preview generation failed for {File}", row.PrimaryFilePath); return null; } - finally - { - try - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - catch (Exception ex) - { - _logger.LogTrace(ex, "Failed to clean up preview temp file {File}", tempFile); - } - } } private void ResetPreview(string key) @@ -2291,7 +2295,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { _textureSelections[row.Key] = selectedTarget; } - var hasSelectedInfo = _textureMetadataHelper.TryGetRecommendationInfo(selectedTarget, out var selectedInfo); + var hasSelectedInfo = TextureMetadataHelper.TryGetRecommendationInfo(selectedTarget, out var selectedInfo); using (ImRaii.Child("textureDetailInfo", new Vector2(-1, 0), true, ImGuiWindowFlags.AlwaysVerticalScrollbar)) { @@ -2425,7 +2429,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase if (row.SuggestedTarget.HasValue) { var recommendedTarget = row.SuggestedTarget.Value; - var hasRecommendationInfo = _textureMetadataHelper.TryGetRecommendationInfo(recommendedTarget, out var recommendedInfo); + var hasRecommendationInfo = TextureMetadataHelper.TryGetRecommendationInfo(recommendedTarget, out var recommendedInfo); var recommendedTitle = hasRecommendationInfo ? recommendedInfo!.Title : recommendedTarget.ToString(); var recommendedDescription = hasRecommendationInfo ? recommendedInfo!.Description @@ -2634,4 +2638,4 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } } -} \ No newline at end of file +} diff --git a/LightlessSync/UI/ProfileTags.cs b/LightlessSync/UI/ProfileTags.cs deleted file mode 100644 index 885eb7a..0000000 --- a/LightlessSync/UI/ProfileTags.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace LightlessSync.UI -{ - public enum ProfileTags - { - SFW = 0, - NSFW = 1, - - RP = 2, - ERP = 3, - No_RP = 4, - No_ERP = 5, - - Venues = 6, - Gpose = 7, - - Limsa = 8, - Gridania = 9, - Ul_dah = 10, - - WUT = 11, - - PVP = 1001, - Ultimate = 1002, - Raids = 1003, - Roulette = 1004, - Crafting = 1005, - Casual = 1006, - Hardcore = 1007, - Glamour = 1008, - Mentor = 1009, - - } -} \ No newline at end of file diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index cda8ac3..0e391ad 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -28,23 +28,16 @@ using LightlessSync.WebAPI; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR.Utils; -using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using Microsoft.AspNetCore.Http.Connections; using Microsoft.Extensions.Logging; -using System; using System.Collections.Concurrent; -using System.Collections.Generic; using System.Diagnostics; using System.Globalization; -using System.Linq; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Numerics; using System.Text; using System.Text.Json; -using FFXIVClientStructs.FFXIV.Client.Game.Object; -using FfxivCharacter = FFXIVClientStructs.FFXIV.Client.Game.Character.Character; -using FfxivCharacterBase = FFXIVClientStructs.FFXIV.Client.Graphics.Scene.CharacterBase; namespace LightlessSync.UI; diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index 22e42aa..2332387 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Interface.ImGuiSeStringRenderer; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using LightlessSync.API.Data; @@ -13,9 +12,6 @@ using LightlessSync.UI.Services; using LightlessSync.UI.Tags; using LightlessSync.Utils; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.Linq; using System.Numerics; namespace LightlessSync.UI; diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 94d3977..3347934 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -4,21 +4,16 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; -using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; -using LightlessSync.API.Dto.User; using LightlessSync.Services; using LightlessSync.Services.Mediator; -using LightlessSync.UI.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; using System.Globalization; -using System.Linq; -using System.Numerics; namespace LightlessSync.UI; diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 823f44a..6a4a465 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -13,10 +13,7 @@ using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; -using System.Collections.Generic; -using System.Linq; using System.Numerics; -using System.Threading.Tasks; namespace LightlessSync.UI; diff --git a/LightlessSync/UI/Tags/ProfileTagRenderer.cs b/LightlessSync/UI/Tags/ProfileTagRenderer.cs index 67147ee..28f0295 100644 --- a/LightlessSync/UI/Tags/ProfileTagRenderer.cs +++ b/LightlessSync/UI/Tags/ProfileTagRenderer.cs @@ -4,8 +4,6 @@ using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using LightlessSync.Utils; using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; using System.Numerics; namespace LightlessSync.UI.Tags; diff --git a/LightlessSync/UI/Tags/ProfileTagService.cs b/LightlessSync/UI/Tags/ProfileTagService.cs index 14b1a45..6f9a3ff 100644 --- a/LightlessSync/UI/Tags/ProfileTagService.cs +++ b/LightlessSync/UI/Tags/ProfileTagService.cs @@ -1,6 +1,3 @@ -using LightlessSync.UI; -using System; -using System.Collections.Generic; using System.Numerics; namespace LightlessSync.UI.Tags; @@ -35,16 +32,16 @@ public sealed class ProfileTagService private static IReadOnlyDictionary CreateTagLibrary() { - var dictionary = new Dictionary + return new Dictionary { - [(int)ProfileTags.SFW] = ProfileTagDefinition.FromIconAndText( + [0] = ProfileTagDefinition.FromIconAndText( 230419, "SFW", background: new Vector4(0.16f, 0.24f, 0.18f, 0.95f), border: new Vector4(0.32f, 0.52f, 0.34f, 0.85f), textColor: new Vector4(0.78f, 0.94f, 0.80f, 1f)), - [(int)ProfileTags.NSFW] = ProfileTagDefinition.FromIconAndText( + [1] = ProfileTagDefinition.FromIconAndText( 230419, "NSFW", background: new Vector4(0.32f, 0.18f, 0.22f, 0.95f), @@ -52,28 +49,28 @@ public sealed class ProfileTagService textColor: new Vector4(1f, 0.82f, 0.86f, 1f)), - [(int)ProfileTags.RP] = ProfileTagDefinition.FromIconAndText( + [2] = ProfileTagDefinition.FromIconAndText( 61545, "RP", background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f), border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f), textColor: new Vector4(0.80f, 0.84f, 1f, 1f)), - [(int)ProfileTags.ERP] = ProfileTagDefinition.FromIconAndText( + [3] = ProfileTagDefinition.FromIconAndText( 61545, "ERP", background: new Vector4(0.20f, 0.20f, 0.30f, 0.95f), border: new Vector4(0.42f, 0.42f, 0.66f, 0.85f), textColor: new Vector4(0.80f, 0.84f, 1f, 1f)), - [(int)ProfileTags.No_RP] = ProfileTagDefinition.FromIconAndText( + [4] = ProfileTagDefinition.FromIconAndText( 230420, "No RP", background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f), border: new Vector4(0.69f, 0.40f, 0.65f, 0.85f), textColor: new Vector4(1f, 0.84f, 1f, 1f)), - [(int)ProfileTags.No_ERP] = ProfileTagDefinition.FromIconAndText( + [5] = ProfileTagDefinition.FromIconAndText( 230420, "No ERP", background: new Vector4(0.30f, 0.18f, 0.30f, 0.95f), @@ -81,14 +78,14 @@ public sealed class ProfileTagService textColor: new Vector4(1f, 0.84f, 1f, 1f)), - [(int)ProfileTags.Venues] = ProfileTagDefinition.FromIconAndText( + [6] = ProfileTagDefinition.FromIconAndText( 60756, "Venues", background: new Vector4(0.18f, 0.24f, 0.28f, 0.95f), border: new Vector4(0.33f, 0.55f, 0.63f, 0.85f), textColor: new Vector4(0.78f, 0.90f, 0.97f, 1f)), - [(int)ProfileTags.Gpose] = ProfileTagDefinition.FromIconAndText( + [7] = ProfileTagDefinition.FromIconAndText( 61546, "GPose", background: new Vector4(0.18f, 0.18f, 0.26f, 0.95f), @@ -96,36 +93,33 @@ public sealed class ProfileTagService textColor: new Vector4(0.80f, 0.82f, 0.96f, 1f)), - [(int)ProfileTags.Limsa] = ProfileTagDefinition.FromIconAndText( + [8] = ProfileTagDefinition.FromIconAndText( 60572, "Limsa"), - [(int)ProfileTags.Gridania] = ProfileTagDefinition.FromIconAndText( + [9] = ProfileTagDefinition.FromIconAndText( 60573, "Gridania"), - [(int)ProfileTags.Ul_dah] = ProfileTagDefinition.FromIconAndText( + [10] = ProfileTagDefinition.FromIconAndText( 60574, "Ul'dah"), - [(int)ProfileTags.WUT] = ProfileTagDefinition.FromIconAndText( + [11] = ProfileTagDefinition.FromIconAndText( 61397, "WU/T"), - [(int)ProfileTags.PVP] = ProfileTagDefinition.FromIcon(61806), - [(int)ProfileTags.Ultimate] = ProfileTagDefinition.FromIcon(61832), - [(int)ProfileTags.Raids] = ProfileTagDefinition.FromIcon(61802), - [(int)ProfileTags.Roulette] = ProfileTagDefinition.FromIcon(61807), - [(int)ProfileTags.Crafting] = ProfileTagDefinition.FromIcon(61816), - [(int)ProfileTags.Casual] = ProfileTagDefinition.FromIcon(61753), - [(int)ProfileTags.Hardcore] = ProfileTagDefinition.FromIcon(61754), - [(int)ProfileTags.Glamour] = ProfileTagDefinition.FromIcon(61759), - [(int)ProfileTags.Mentor] = ProfileTagDefinition.FromIcon(61760) - + [1001] = ProfileTagDefinition.FromIcon(61806), // PVP + [1002] = ProfileTagDefinition.FromIcon(61832), // Ultimate + [1003] = ProfileTagDefinition.FromIcon(61802), // Raids + [1004] = ProfileTagDefinition.FromIcon(61807), // Roulette + [1005] = ProfileTagDefinition.FromIcon(61816), // Crafting + [1006] = ProfileTagDefinition.FromIcon(61753), // Casual + [1007] = ProfileTagDefinition.FromIcon(61754), // Hardcore + [1008] = ProfileTagDefinition.FromIcon(61759), // Glamour + [1009] = ProfileTagDefinition.FromIcon(61760) // Mentor }; - - return dictionary; } } diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index d7fff31..0ce7890 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -1,4 +1,3 @@ -using Dalamud.Utility; using K4os.Compression.LZ4.Legacy; using LightlessSync.API.Data; using LightlessSync.API.Dto.Files; @@ -10,13 +9,9 @@ using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; -using System; using System.Collections.Concurrent; -using System.IO; using System.Net; using System.Net.Http.Json; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.LightlessConfiguration; namespace LightlessSync.WebAPI.Files; diff --git a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs index d6937c4..ac77b23 100644 --- a/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs +++ b/LightlessSync/WebAPI/Files/FileTransferOrchestrator.cs @@ -4,7 +4,6 @@ using LightlessSync.WebAPI.Files.Models; using LightlessSync.WebAPI.SignalR; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; -using System.Net.Http; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Net.Sockets; diff --git a/LightlessSync/WebAPI/Files/FileUploadManager.cs b/LightlessSync/WebAPI/Files/FileUploadManager.cs index 4fb89b7..b5db541 100644 --- a/LightlessSync/WebAPI/Files/FileUploadManager.cs +++ b/LightlessSync/WebAPI/Files/FileUploadManager.cs @@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Collections.Concurrent; -using System.Threading; namespace LightlessSync.WebAPI.Files; -- 2.49.1 From 5ab67c70d62d2322e36a9123958cfa8a19cf33b7 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 27 Nov 2025 00:17:03 +0100 Subject: [PATCH 051/140] Redone nameplate service (#93) Co-authored-by: cake Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/93 Reviewed-by: defnotken --- LightlessSync/Plugin.cs | 4 +- LightlessSync/Services/NameplateService.cs | 257 ++++++++++++++++----- LightlessSync/UI/DtrEntry.cs | 2 +- LightlessSync/UI/SettingsUi.cs | 8 - 4 files changed, 201 insertions(+), 70 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 542d9a1..1842989 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -340,8 +340,8 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), namePlateGui, clientState, - s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), clientState, gameGui, objectTable, gameInteropProvider, + s.GetRequiredService(),s.GetRequiredService())); collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), clientState, s.GetRequiredService())); diff --git a/LightlessSync/Services/NameplateService.cs b/LightlessSync/Services/NameplateService.cs index 84b6d64..4ca8a2f 100644 --- a/LightlessSync/Services/NameplateService.cs +++ b/LightlessSync/Services/NameplateService.cs @@ -1,115 +1,254 @@ using Dalamud.Game.ClientState.Objects.Enums; -using Dalamud.Game.Gui.NamePlate; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.NativeWrapper; using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Hooking; using Dalamud.Plugin.Services; using Dalamud.Utility; +using Dalamud.Utility.Signatures; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; -using LightlessSync.UI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; +using System.Numerics; +using static LightlessSync.UI.DtrEntry; +using LSeStringBuilder = Lumina.Text.SeStringBuilder; namespace LightlessSync.Services; -public class NameplateService : DisposableMediatorSubscriberBase +/// +/// NameplateService is used for coloring our nameplates based on the settings of the user. +/// +public unsafe class NameplateService : DisposableMediatorSubscriberBase { + private delegate nint UpdateNameplateDelegate(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex); + + // Glyceri, Thanks :bow: + [Signature("40 53 55 57 41 56 48 81 EC ?? ?? ?? ?? 48 8B 84 24", DetourName = nameof(UpdateNameplateDetour))] + private readonly Hook? _nameplateHook = null; + private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly IClientState _clientState; - private readonly INamePlateGui _namePlateGui; + private readonly IGameGui _gameGui; + private readonly IObjectTable _objectTable; private readonly PairUiService _pairUiService; public NameplateService(ILogger logger, LightlessConfigService configService, - INamePlateGui namePlateGui, IClientState clientState, - PairUiService pairUiService, - LightlessMediator lightlessMediator) : base(logger, lightlessMediator) + IGameGui gameGui, + IObjectTable objectTable, + IGameInteropProvider interop, + LightlessMediator lightlessMediator, + PairUiService pairUiService) : base(logger, lightlessMediator) { _logger = logger; _configService = configService; - _namePlateGui = namePlateGui; _clientState = clientState; + _gameGui = gameGui; + _objectTable = objectTable; _pairUiService = pairUiService; - _namePlateGui.OnNamePlateUpdate += OnNamePlateUpdate; - _namePlateGui.RequestRedraw(); - Mediator.Subscribe(this, (_) => _namePlateGui.RequestRedraw()); + interop.InitializeFromAttributes(this); + _nameplateHook?.Enable(); + Refresh(); + + Mediator.Subscribe(this, (_) => Refresh()); } - private void OnNamePlateUpdate(INamePlateUpdateContext context, IReadOnlyList handlers) + /// + /// Detour for the game's internal nameplate update function. + /// This will be called whenever the client updates any nameplate. + /// + /// We hook into it to apply our own nameplate coloring logic via , + /// + private nint UpdateNameplateDetour(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo, NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex) { - if (!_configService.Current.IsNameplateColorsEnabled || (_configService.Current.IsNameplateColorsEnabled && _clientState.IsPvPExcludingDen)) + try + { + SetNameplate(namePlateInfo, battleChara); + } + catch (Exception e) + { + _logger.LogError(e, "Error in NameplateService UpdateNameplateDetour"); + } + + return _nameplateHook!.Original(raptureAtkModule, namePlateInfo, numArray, stringArray, battleChara, numArrayIndex, stringArrayIndex); + } + + /// + /// Determine if the player should be colored based on conditions (isFriend, IsInParty) + /// + /// Player character that will be checked + /// All visible users in the current object table + /// PLayer should or shouldnt be colored based on the result. True means colored + private bool ShouldColorPlayer(IPlayerCharacter playerCharacter, HashSet visibleUserIds) + { + if (!visibleUserIds.Contains(playerCharacter.GameObjectId)) + return false; + + var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); + var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend); + + bool partyColorAllowed = _configService.Current.overridePartyColor && isInParty; + bool friendColorAllowed = _configService.Current.overrideFriendColor && isFriend; + + if ((isInParty && !partyColorAllowed) || (isFriend && !friendColorAllowed)) + return false; + + return true; + } + + /// + /// Setting up the nameplate of the user to be colored + /// + /// Information given from the Signature to be updated + /// Character from FF + private void SetNameplate(RaptureAtkModule.NamePlateInfo* namePlateInfo, BattleChara* battleChara) + { + if (!_configService.Current.IsNameplateColorsEnabled || _clientState.IsPvPExcludingDen) + return; + if (namePlateInfo == null || battleChara == null) + return; + + var obj = _objectTable.FirstOrDefault(o => o.Address == (nint)battleChara); + if (obj is not IPlayerCharacter player) return; var snapshot = _pairUiService.GetSnapshot(); var visibleUsersIds = snapshot.PairsByUid.Values - .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) - .Select(u => (ulong)u.PlayerCharacterId) - .ToHashSet(); + .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) + .Select(u => (ulong)u.PlayerCharacterId) + .ToHashSet(); - var now = DateTime.UtcNow; - var colors = _configService.Current.NameplateColors; + //Check if player should be colored + if (!ShouldColorPlayer(player, visibleUsersIds)) + return; - foreach (var handler in handlers) - { - var playerCharacter = handler.PlayerCharacter; - if (playerCharacter == null) - continue; + var originalName = player.Name.ToString(); - var isInParty = playerCharacter.StatusFlags.HasFlag(StatusFlags.PartyMember); - var isFriend = playerCharacter.StatusFlags.HasFlag(StatusFlags.Friend); - bool partyColorAllowed = (_configService.Current.overridePartyColor && isInParty); - bool friendColorAllowed = (_configService.Current.overrideFriendColor && isFriend); + //Check if not null of the name + if (string.IsNullOrEmpty(originalName)) + return; - if (visibleUsersIds.Contains(handler.GameObjectId) && - !( - (isInParty && !partyColorAllowed) || - (isFriend && !friendColorAllowed) - )) - { - handler.NameParts.TextWrap = CreateTextWrap(colors); + //Check if any characters/symbols are forbidden + if (HasForbiddenSeStringChars(originalName)) + return; - if (_configService.Current.overrideFcTagColor) - { - bool hasActualFcTag = playerCharacter.CompanyTag.TextValue.Length > 0; - bool isFromDifferentRealm = playerCharacter.HomeWorld.RowId != playerCharacter.CurrentWorld.RowId; - bool shouldColorFcArea = hasActualFcTag || (!hasActualFcTag && isFromDifferentRealm); + //Swap color channels as we store them in BGR format as FF loves that + var cfgColors = SwapColorChannels(_configService.Current.NameplateColors); + var coloredName = WrapStringInColor(originalName, cfgColors.Glow, cfgColors.Foreground); - if (shouldColorFcArea) - { - handler.FreeCompanyTagParts.OuterWrap = CreateTextWrap(colors); - handler.FreeCompanyTagParts.TextWrap = CreateTextWrap(colors); - } - } - } - } + //Replace string of nameplate with our colored one + namePlateInfo->Name.SetString(coloredName.EncodeWithNullTerminator()); } + /// + /// Converts Uint code to Vector4 as we store Colors in Uint in our config, needed for lumina + /// + /// Color code + /// Vector4 Color + private static Vector4 RgbUintToVector4(uint rgb) + { + float r = ((rgb >> 16) & 0xFF) / 255f; + float g = ((rgb >> 8) & 0xFF) / 255f; + float b = (rgb & 0xFF) / 255f; + return new Vector4(r, g, b, 1f); + } + + /// + /// Checks if the string has any forbidden characters/symbols as the string builder wouldnt append. + /// + /// String that has to be checked + /// Contains forbidden characters/symbols or not + private static bool HasForbiddenSeStringChars(string s) + { + if (string.IsNullOrEmpty(s)) + return false; + + foreach (var ch in s) + { + if (ch == '\0' || ch == '\u0002') + return true; + } + + return false; + } + + /// + /// Wraps the given string with the given edge and text color. + /// + /// String that has to be wrapped + /// Edge(border) color + /// Text color + /// Color wrapped SeString + public static SeString WrapStringInColor(string text, uint? edgeColor = null, uint? textColor = null) + { + if (string.IsNullOrEmpty(text)) + return SeString.Empty; + + var builder = new LSeStringBuilder(); + + if (textColor is uint tc) + builder.PushColorRgba(RgbUintToVector4(tc)); + + if (edgeColor is uint ec) + builder.PushEdgeColorRgba(RgbUintToVector4(ec)); + + builder.Append(text); + + if (edgeColor != null) + builder.PopEdgeColor(); + + if (textColor != null) + builder.PopColor(); + + return builder.ToReadOnlySeString().ToDalamudString(); + } + + /// + /// Request redraw of nameplates + /// public void RequestRedraw() { - _namePlateGui.RequestRedraw(); + Refresh(); } - private static (SeString, SeString) CreateTextWrap(DtrEntry.Colors color) + /// + /// Toggles the refresh of the Nameplate addon + /// + protected void Refresh() { - var left = new Lumina.Text.SeStringBuilder(); - var right = new Lumina.Text.SeStringBuilder(); + AtkUnitBasePtr namePlateAddon = _gameGui.GetAddonByName("NamePlate"); - left.PushColorRgba(color.Foreground); - right.PopColor(); + if (namePlateAddon.IsNull) + { + _logger.LogInformation("NamePlate addon is null, cannot refresh nameplates."); + return; + } - left.PushEdgeColorRgba(color.Glow); - right.PopEdgeColor(); + var addonNamePlate = (AddonNamePlate*)namePlateAddon.Address; - return (left.ToReadOnlySeString().ToDalamudString(), right.ToReadOnlySeString().ToDalamudString()); + if (addonNamePlate == null) + { + _logger.LogInformation("addonNamePlate addon is null, cannot refresh nameplates."); + return; + } + + addonNamePlate->DoFullUpdate = 1; } protected override void Dispose(bool disposing) { - base.Dispose(disposing); + if (disposing) + { + _nameplateHook?.Dispose(); + } - _namePlateGui.OnNamePlateUpdate -= OnNamePlateUpdate; - _namePlateGui.RequestRedraw(); + base.Dispose(disposing); } } \ No newline at end of file diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index f965181..8251fb2 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -490,7 +490,7 @@ public sealed class DtrEntry : IDisposable, IHostedService private const byte _colorTypeForeground = 0x13; private const byte _colorTypeGlow = 0x14; - private static Colors SwapColorChannels(Colors colors) + internal static Colors SwapColorChannels(Colors colors) => new(SwapColorComponent(colors.Foreground), SwapColorComponent(colors.Glow)); private static uint SwapColorComponent(uint color) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 0e391ad..1934d85 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -2244,7 +2244,6 @@ public class SettingsUi : WindowMediatorSubscriberBase var nameColors = _configService.Current.NameplateColors; var isFriendOverride = _configService.Current.overrideFriendColor; var isPartyOverride = _configService.Current.overridePartyColor; - var isFcTagOverride = _configService.Current.overrideFcTagColor; if (ImGui.Checkbox("Override name color of visible paired players", ref nameColorsEnabled)) { @@ -2278,13 +2277,6 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); _nameplateService.RequestRedraw(); } - - if (ImGui.Checkbox("Override FC tag color", ref isFcTagOverride)) - { - _configService.Current.overrideFcTagColor = isFcTagOverride; - _configService.Save(); - _nameplateService.RequestRedraw(); - } } ImGui.Spacing(); -- 2.49.1 From d995afcf484b4b5d5df9289553011864f0612b9b Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 28 Nov 2025 00:33:46 +0900 Subject: [PATCH 052/140] work done on the ipc --- .../Interop/Ipc/Framework/IpcFramework.cs | 193 ++++++ LightlessSync/Interop/Ipc/IIpcCaller.cs | 7 - LightlessSync/Interop/Ipc/IpcCallerBrio.cs | 48 +- .../Interop/Ipc/IpcCallerCustomize.cs | 34 +- .../Interop/Ipc/IpcCallerGlamourer.cs | 90 +-- LightlessSync/Interop/Ipc/IpcCallerHeels.cs | 40 +- .../Interop/Ipc/IpcCallerHonorific.cs | 45 +- LightlessSync/Interop/Ipc/IpcCallerMoodles.cs | 44 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 630 +++++------------- .../Interop/Ipc/IpcCallerPetNames.cs | 62 +- LightlessSync/Interop/Ipc/IpcProvider.cs | 60 +- .../Interop/Ipc/Penumbra/PenumbraBase.cs | 27 + .../Ipc/Penumbra/PenumbraCollections.cs | 197 ++++++ .../Interop/Ipc/Penumbra/PenumbraRedraw.cs | 81 +++ .../Interop/Ipc/Penumbra/PenumbraResource.cs | 141 ++++ .../Interop/Ipc/Penumbra/PenumbraTexture.cs | 121 ++++ LightlessSync/Plugin.cs | 2 +- 17 files changed, 1199 insertions(+), 623 deletions(-) create mode 100644 LightlessSync/Interop/Ipc/Framework/IpcFramework.cs delete mode 100644 LightlessSync/Interop/Ipc/IIpcCaller.cs create mode 100644 LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs create mode 100644 LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs create mode 100644 LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs create mode 100644 LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs create mode 100644 LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs diff --git a/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs new file mode 100644 index 0000000..dbf1c15 --- /dev/null +++ b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs @@ -0,0 +1,193 @@ +using Dalamud.Plugin; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Interop.Ipc.Framework; + +public enum IpcConnectionState +{ + Unknown = 0, + MissingPlugin = 1, + VersionMismatch = 2, + PluginDisabled = 3, + NotReady = 4, + Available = 5, + Error = 6, +} + +public sealed record IpcServiceDescriptor(string InternalName, string DisplayName, Version MinimumVersion) +{ + public override string ToString() + => $"{DisplayName} (>= {MinimumVersion})"; +} + +public interface IIpcService : IDisposable +{ + IpcServiceDescriptor Descriptor { get; } + IpcConnectionState State { get; } + IDalamudPluginInterface PluginInterface { get; } + bool APIAvailable { get; } + void CheckAPI(); +} + +public interface IIpcInterop : IDisposable +{ + string Name { get; } + void OnConnectionStateChanged(IpcConnectionState state); +} + +public abstract class IpcInteropBase : IIpcInterop +{ + protected IpcInteropBase(ILogger logger) + { + Logger = logger; + } + + protected ILogger Logger { get; } + + protected IpcConnectionState State { get; private set; } = IpcConnectionState.Unknown; + + protected bool IsAvailable => State == IpcConnectionState.Available; + + public abstract string Name { get; } + + public void OnConnectionStateChanged(IpcConnectionState state) + { + if (State == state) + { + return; + } + + var previous = State; + State = state; + HandleStateChange(previous, state); + } + + protected abstract void HandleStateChange(IpcConnectionState previous, IpcConnectionState current); + + public virtual void Dispose() + { + } +} + +public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcService +{ + private readonly List _interops = new(); + + protected IpcServiceBase( + ILogger logger, + LightlessMediator mediator, + IDalamudPluginInterface pluginInterface, + IpcServiceDescriptor descriptor) : base(logger, mediator) + { + PluginInterface = pluginInterface; + Descriptor = descriptor; + } + + protected IDalamudPluginInterface PluginInterface { get; } + + IDalamudPluginInterface IIpcService.PluginInterface => PluginInterface; + + protected IpcServiceDescriptor Descriptor { get; } + + IpcServiceDescriptor IIpcService.Descriptor => Descriptor; + + public IpcConnectionState State { get; private set; } = IpcConnectionState.Unknown; + + public bool APIAvailable => State == IpcConnectionState.Available; + + public virtual void CheckAPI() + { + var newState = EvaluateState(); + UpdateState(newState); + } + + protected virtual IpcConnectionState EvaluateState() + { + try + { + var plugin = PluginInterface.InstalledPlugins + .FirstOrDefault(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase)); + + if (plugin == null) + { + return IpcConnectionState.MissingPlugin; + } + + if (plugin.Version < Descriptor.MinimumVersion) + { + return IpcConnectionState.VersionMismatch; + } + + if (!IsPluginEnabled()) + { + return IpcConnectionState.PluginDisabled; + } + + if (!IsPluginReady()) + { + return IpcConnectionState.NotReady; + } + + return IpcConnectionState.Available; + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to evaluate IPC state for {Service}", Descriptor.DisplayName); + return IpcConnectionState.Error; + } + } + + protected virtual bool IsPluginEnabled() + => true; + + protected virtual bool IsPluginReady() + => true; + + protected TInterop RegisterInterop(TInterop interop) + where TInterop : IIpcInterop + { + _interops.Add(interop); + interop.OnConnectionStateChanged(State); + return interop; + } + + private void UpdateState(IpcConnectionState newState) + { + if (State == newState) + { + return; + } + + var previous = State; + State = newState; + OnConnectionStateChanged(previous, newState); + + foreach (var interop in _interops) + { + interop.OnConnectionStateChanged(newState); + } + } + + protected virtual void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current) + { + Logger.LogTrace("{Service} IPC state transitioned from {Previous} to {Current}", Descriptor.DisplayName, previous, current); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + for (var i = _interops.Count - 1; i >= 0; --i) + { + _interops[i].Dispose(); + } + + _interops.Clear(); + } +} diff --git a/LightlessSync/Interop/Ipc/IIpcCaller.cs b/LightlessSync/Interop/Ipc/IIpcCaller.cs deleted file mode 100644 index 8519d1a..0000000 --- a/LightlessSync/Interop/Ipc/IIpcCaller.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace LightlessSync.Interop.Ipc; - -public interface IIpcCaller : IDisposable -{ - bool APIAvailable { get; } - void CheckAPI(); -} diff --git a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs index 5728464..83105d9 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs @@ -2,15 +2,19 @@ using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using LightlessSync.API.Dto.CharaData; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; +using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Numerics; using System.Text.Json.Nodes; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerBrio : IIpcCaller +public sealed class IpcCallerBrio : IpcServiceBase { + private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(0, 0, 0, 0)); + private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtilService; private readonly ICallGateSubscriber<(int, int)> _brioApiVersion; @@ -25,10 +29,8 @@ public sealed class IpcCallerBrio : IIpcCaller private readonly ICallGateSubscriber _brioFreezePhysics; - public bool APIAvailable { get; private set; } - public IpcCallerBrio(ILogger logger, IDalamudPluginInterface dalamudPluginInterface, - DalamudUtilService dalamudUtilService) + DalamudUtilService dalamudUtilService, LightlessMediator mediator) : base(logger, mediator, dalamudPluginInterface, BrioDescriptor) { _logger = logger; _dalamudUtilService = dalamudUtilService; @@ -46,19 +48,6 @@ public sealed class IpcCallerBrio : IIpcCaller CheckAPI(); } - public void CheckAPI() - { - try - { - var version = _brioApiVersion.InvokeFunc(); - APIAvailable = (version.Item1 == 2 && version.Item2 >= 0); - } - catch - { - APIAvailable = false; - } - } - public async Task SpawnActorAsync() { if (!APIAvailable) return null; @@ -140,7 +129,30 @@ public sealed class IpcCallerBrio : IIpcCaller return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetPoseFromJson.InvokeFunc(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false); } - public void Dispose() + protected override IpcConnectionState EvaluateState() { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + + try + { + var version = _brioApiVersion.InvokeFunc(); + return version.Item1 == 2 && version.Item2 >= 0 + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query Brio IPC version"); + return IpcConnectionState.Error; + } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); } } diff --git a/LightlessSync/Interop/Ipc/IpcCallerCustomize.cs b/LightlessSync/Interop/Ipc/IpcCallerCustomize.cs index 60feaba..fd1b88c 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerCustomize.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerCustomize.cs @@ -2,6 +2,7 @@ using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using Dalamud.Utility; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -9,8 +10,10 @@ using System.Text; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerCustomize : IIpcCaller +public sealed class IpcCallerCustomize : IpcServiceBase { + private static readonly IpcServiceDescriptor CustomizeDescriptor = new("CustomizePlus", "Customize+", new Version(0, 0, 0, 0)); + private readonly ICallGateSubscriber<(int, int)> _customizePlusApiVersion; private readonly ICallGateSubscriber _customizePlusGetActiveProfile; private readonly ICallGateSubscriber _customizePlusGetProfileById; @@ -23,7 +26,7 @@ public sealed class IpcCallerCustomize : IIpcCaller private readonly LightlessMediator _lightlessMediator; public IpcCallerCustomize(ILogger logger, IDalamudPluginInterface dalamudPluginInterface, - DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator) + DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator) : base(logger, lightlessMediator, dalamudPluginInterface, CustomizeDescriptor) { _customizePlusApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("CustomizePlus.General.GetApiVersion"); _customizePlusGetActiveProfile = dalamudPluginInterface.GetIpcSubscriber("CustomizePlus.Profile.GetActiveProfileIdOnCharacter"); @@ -41,8 +44,6 @@ public sealed class IpcCallerCustomize : IIpcCaller CheckAPI(); } - public bool APIAvailable { get; private set; } = false; - public async Task RevertAsync(nint character) { if (!APIAvailable) return; @@ -113,16 +114,25 @@ public sealed class IpcCallerCustomize : IIpcCaller return Convert.ToBase64String(Encoding.UTF8.GetBytes(scale)); } - public void CheckAPI() + protected override IpcConnectionState EvaluateState() { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + try { var version = _customizePlusApiVersion.InvokeFunc(); - APIAvailable = (version.Item1 == 6 && version.Item2 >= 0); + return version.Item1 == 6 && version.Item2 >= 0 + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; } - catch + catch (Exception ex) { - APIAvailable = false; + Logger.LogDebug(ex, "Failed to query Customize+ API version"); + return IpcConnectionState.Error; } } @@ -132,8 +142,14 @@ public sealed class IpcCallerCustomize : IIpcCaller _lightlessMediator.Publish(new CustomizePlusMessage(obj?.Address ?? null)); } - public void Dispose() + protected override void Dispose(bool disposing) { + base.Dispose(disposing); + if (!disposing) + { + return; + } + _customizePlusOnScaleUpdate.Unsubscribe(OnCustomizePlusScaleChange); } } diff --git a/LightlessSync/Interop/Ipc/IpcCallerGlamourer.cs b/LightlessSync/Interop/Ipc/IpcCallerGlamourer.cs index 8763188..4f9f53d 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerGlamourer.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerGlamourer.cs @@ -2,6 +2,7 @@ using Dalamud.Plugin; using Glamourer.Api.Helpers; using Glamourer.Api.IpcSubscribers; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; @@ -10,8 +11,9 @@ using Microsoft.Extensions.Logging; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcCaller +public sealed class IpcCallerGlamourer : IpcServiceBase { + private static readonly IpcServiceDescriptor GlamourerDescriptor = new("Glamourer", "Glamourer", new Version(1, 3, 0, 10)); private readonly ILogger _logger; private readonly IDalamudPluginInterface _pi; private readonly DalamudUtilService _dalamudUtil; @@ -31,7 +33,7 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC private readonly uint LockCode = 0x6D617265; public IpcCallerGlamourer(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator, - RedrawManager redrawManager) : base(logger, lightlessMediator) + RedrawManager redrawManager) : base(logger, lightlessMediator, pi, GlamourerDescriptor) { _glamourerApiVersions = new ApiVersion(pi); _glamourerGetAllCustomization = new GetStateBase64(pi); @@ -62,47 +64,6 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC _glamourerStateChanged?.Dispose(); } - public bool APIAvailable { get; private set; } - - public void CheckAPI() - { - bool apiAvailable = false; - try - { - bool versionValid = (_pi.InstalledPlugins - .FirstOrDefault(p => string.Equals(p.InternalName, "Glamourer", StringComparison.OrdinalIgnoreCase)) - ?.Version ?? new Version(0, 0, 0, 0)) >= new Version(1, 3, 0, 10); - try - { - var version = _glamourerApiVersions.Invoke(); - if (version is { Major: 1, Minor: >= 1 } && versionValid) - { - apiAvailable = true; - } - } - catch - { - // ignore - } - _shownGlamourerUnavailable = _shownGlamourerUnavailable && !apiAvailable; - - APIAvailable = apiAvailable; - } - catch - { - APIAvailable = apiAvailable; - } - finally - { - if (!apiAvailable && !_shownGlamourerUnavailable) - { - _shownGlamourerUnavailable = true; - _lightlessMediator.Publish(new NotificationMessage("Glamourer inactive", "Your Glamourer installation is not active or out of date. Update Glamourer to continue to use Lightless. If you just updated Glamourer, ignore this message.", - NotificationType.Error)); - } - } - } - public async Task ApplyAllAsync(ILogger logger, GameObjectHandler handler, string? customization, Guid applicationId, CancellationToken token, bool fireAndForget = false) { if (!APIAvailable || string.IsNullOrEmpty(customization) || _dalamudUtil.IsZoning) return; @@ -210,6 +171,49 @@ public sealed class IpcCallerGlamourer : DisposableMediatorSubscriberBase, IIpcC } } + protected override IpcConnectionState EvaluateState() + { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + + try + { + var version = _glamourerApiVersions.Invoke(); + return version is { Major: 1, Minor: >= 1 } + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query Glamourer API version"); + return IpcConnectionState.Error; + } + } + + protected override void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current) + { + base.OnConnectionStateChanged(previous, current); + + if (current == IpcConnectionState.Available) + { + _shownGlamourerUnavailable = false; + return; + } + + if (_shownGlamourerUnavailable || current == IpcConnectionState.Unknown) + { + return; + } + + _shownGlamourerUnavailable = true; + _lightlessMediator.Publish(new NotificationMessage("Glamourer inactive", + "Your Glamourer installation is not active or out of date. Update Glamourer to continue to use Lightless. If you just updated Glamourer, ignore this message.", + NotificationType.Error)); + } + private void GlamourerChanged(nint address) { _lightlessMediator.Publish(new GlamourerChangedMessage(address)); diff --git a/LightlessSync/Interop/Ipc/IpcCallerHeels.cs b/LightlessSync/Interop/Ipc/IpcCallerHeels.cs index 69b359c..23fe192 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerHeels.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerHeels.cs @@ -1,13 +1,16 @@ using Dalamud.Plugin; using Dalamud.Plugin.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerHeels : IIpcCaller +public sealed class IpcCallerHeels : IpcServiceBase { + private static readonly IpcServiceDescriptor HeelsDescriptor = new("SimpleHeels", "Simple Heels", new Version(0, 0, 0, 0)); + private readonly ILogger _logger; private readonly LightlessMediator _lightlessMediator; private readonly DalamudUtilService _dalamudUtil; @@ -18,6 +21,7 @@ public sealed class IpcCallerHeels : IIpcCaller private readonly ICallGateSubscriber _heelsUnregisterPlayer; public IpcCallerHeels(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, LightlessMediator lightlessMediator) + : base(logger, lightlessMediator, pi, HeelsDescriptor) { _logger = logger; _lightlessMediator = lightlessMediator; @@ -32,8 +36,26 @@ public sealed class IpcCallerHeels : IIpcCaller CheckAPI(); } + protected override IpcConnectionState EvaluateState() + { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } - public bool APIAvailable { get; private set; } = false; + try + { + return _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 } + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query SimpleHeels API version"); + return IpcConnectionState.Error; + } + } private void HeelsOffsetChange(string offset) { @@ -74,20 +96,14 @@ public sealed class IpcCallerHeels : IIpcCaller }).ConfigureAwait(false); } - public void CheckAPI() + protected override void Dispose(bool disposing) { - try + base.Dispose(disposing); + if (!disposing) { - APIAvailable = _heelsGetApiVersion.InvokeFunc() is { Item1: 2, Item2: >= 1 }; + return; } - catch - { - APIAvailable = false; - } - } - public void Dispose() - { _heelsOffsetUpdate.Unsubscribe(HeelsOffsetChange); } } diff --git a/LightlessSync/Interop/Ipc/IpcCallerHonorific.cs b/LightlessSync/Interop/Ipc/IpcCallerHonorific.cs index 58588c5..4a2ed5d 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerHonorific.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerHonorific.cs @@ -1,6 +1,7 @@ using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -8,8 +9,10 @@ using System.Text; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerHonorific : IIpcCaller +public sealed class IpcCallerHonorific : IpcServiceBase { + private static readonly IpcServiceDescriptor HonorificDescriptor = new("Honorific", "Honorific", new Version(0, 0, 0, 0)); + private readonly ICallGateSubscriber<(uint major, uint minor)> _honorificApiVersion; private readonly ICallGateSubscriber _honorificClearCharacterTitle; private readonly ICallGateSubscriber _honorificDisposing; @@ -22,7 +25,7 @@ public sealed class IpcCallerHonorific : IIpcCaller private readonly DalamudUtilService _dalamudUtil; public IpcCallerHonorific(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, - LightlessMediator lightlessMediator) + LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, HonorificDescriptor) { _logger = logger; _lightlessMediator = lightlessMediator; @@ -41,23 +44,14 @@ public sealed class IpcCallerHonorific : IIpcCaller CheckAPI(); } - - public bool APIAvailable { get; private set; } = false; - - public void CheckAPI() + protected override void Dispose(bool disposing) { - try + base.Dispose(disposing); + if (!disposing) { - APIAvailable = _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 }; + return; } - catch - { - APIAvailable = false; - } - } - public void Dispose() - { _honorificLocalCharacterTitleChanged.Unsubscribe(OnHonorificLocalCharacterTitleChanged); _honorificDisposing.Unsubscribe(OnHonorificDisposing); _honorificReady.Unsubscribe(OnHonorificReady); @@ -113,6 +107,27 @@ public sealed class IpcCallerHonorific : IIpcCaller } } + protected override IpcConnectionState EvaluateState() + { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + + try + { + return _honorificApiVersion.InvokeFunc() is { Item1: 3, Item2: >= 1 } + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query Honorific API version"); + return IpcConnectionState.Error; + } + } + private void OnHonorificDisposing() { _lightlessMediator.Publish(new HonorificMessage(string.Empty)); diff --git a/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs b/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs index 610ece4..e8b1b76 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs @@ -1,14 +1,17 @@ using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerMoodles : IIpcCaller +public sealed class IpcCallerMoodles : IpcServiceBase { + private static readonly IpcServiceDescriptor MoodlesDescriptor = new("Moodles", "Moodles", new Version(0, 0, 0, 0)); + private readonly ICallGateSubscriber _moodlesApiVersion; private readonly ICallGateSubscriber _moodlesOnChange; private readonly ICallGateSubscriber _moodlesGetStatus; @@ -19,7 +22,7 @@ public sealed class IpcCallerMoodles : IIpcCaller private readonly LightlessMediator _lightlessMediator; public IpcCallerMoodles(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, - LightlessMediator lightlessMediator) + LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, MoodlesDescriptor) { _logger = logger; _dalamudUtil = dalamudUtil; @@ -41,22 +44,14 @@ public sealed class IpcCallerMoodles : IIpcCaller _lightlessMediator.Publish(new MoodlesMessage(character.Address)); } - public bool APIAvailable { get; private set; } = false; - - public void CheckAPI() + protected override void Dispose(bool disposing) { - try + base.Dispose(disposing); + if (!disposing) { - APIAvailable = _moodlesApiVersion.InvokeFunc() == 3; + return; } - catch - { - APIAvailable = false; - } - } - public void Dispose() - { _moodlesOnChange.Unsubscribe(OnMoodlesChange); } @@ -101,4 +96,25 @@ public sealed class IpcCallerMoodles : IIpcCaller _logger.LogWarning(e, "Could not Set Moodles Status"); } } + + protected override IpcConnectionState EvaluateState() + { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + + try + { + return _moodlesApiVersion.InvokeFunc() == 3 + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query Moodles API version"); + return IpcConnectionState.Error; + } + } } diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index 4135642..4169e5c 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -1,4 +1,6 @@ -using Dalamud.Plugin; +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Interop.Ipc.Penumbra; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; @@ -8,520 +10,210 @@ using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; -using System.Collections.Concurrent; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerPenumbra : DisposableMediatorSubscriberBase, IIpcCaller +public sealed class IpcCallerPenumbra : IpcServiceBase { - private readonly IDalamudPluginInterface _pi; - private readonly DalamudUtilService _dalamudUtil; - private readonly LightlessMediator _lightlessMediator; - private readonly RedrawManager _redrawManager; - private readonly ActorObjectService _actorObjectService; - private bool _shownPenumbraUnavailable = false; - private string? _penumbraModDirectory; - public string? ModDirectory - { - get => _penumbraModDirectory; - private set - { - if (!string.Equals(_penumbraModDirectory, value, StringComparison.Ordinal)) - { - _penumbraModDirectory = value; - _lightlessMediator.Publish(new PenumbraDirectoryChangedMessage(_penumbraModDirectory)); - } - } - } + private static readonly IpcServiceDescriptor PenumbraDescriptor = new("Penumbra", "Penumbra", new Version(1, 2, 0, 22)); - private readonly ConcurrentDictionary _penumbraRedrawRequests = new(); - private readonly ConcurrentDictionary _trackedActors = new(); + private readonly PenumbraCollections _collections; + private readonly PenumbraResource _resources; + private readonly PenumbraRedraw _redraw; + private readonly PenumbraTexture _textures; - private readonly EventSubscriber _penumbraDispose; - private readonly EventSubscriber _penumbraGameObjectResourcePathResolved; - private readonly EventSubscriber _penumbraInit; - private readonly EventSubscriber _penumbraModSettingChanged; - private readonly EventSubscriber _penumbraObjectIsRedrawn; - - private readonly AddTemporaryMod _penumbraAddTemporaryMod; - private readonly AssignTemporaryCollection _penumbraAssignTemporaryCollection; - private readonly ConvertTextureFile _penumbraConvertTextureFile; - private readonly CreateTemporaryCollection _penumbraCreateNamedTemporaryCollection; private readonly GetEnabledState _penumbraEnabled; - private readonly GetPlayerMetaManipulations _penumbraGetMetaManipulations; - private readonly RedrawObject _penumbraRedraw; - private readonly DeleteTemporaryCollection _penumbraRemoveTemporaryCollection; - private readonly RemoveTemporaryMod _penumbraRemoveTemporaryMod; - private readonly GetModDirectory _penumbraResolveModDir; - private readonly ResolvePlayerPathsAsync _penumbraResolvePaths; - private readonly GetGameObjectResourcePaths _penumbraResourcePaths; - //private readonly GetPlayerResourcePaths _penumbraPlayerResourcePaths; - private readonly GetCollections _penumbraGetCollections; - private readonly ConcurrentDictionary _activeTemporaryCollections = new(); - private int _performedInitialCleanup; + private readonly GetModDirectory _penumbraGetModDirectory; + private readonly EventSubscriber _penumbraInit; + private readonly EventSubscriber _penumbraDispose; + private readonly EventSubscriber _penumbraModSettingChanged; - public IpcCallerPenumbra(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, - LightlessMediator lightlessMediator, RedrawManager redrawManager, ActorObjectService actorObjectService) : base(logger, lightlessMediator) + private bool _shownPenumbraUnavailable; + private string? _modDirectory; + + public IpcCallerPenumbra( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator, + RedrawManager redrawManager, + ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor) { - _pi = pi; - _dalamudUtil = dalamudUtil; - _lightlessMediator = lightlessMediator; - _redrawManager = redrawManager; - _actorObjectService = actorObjectService; - _penumbraInit = Initialized.Subscriber(pi, PenumbraInit); - _penumbraDispose = Disposed.Subscriber(pi, PenumbraDispose); - _penumbraResolveModDir = new GetModDirectory(pi); - _penumbraRedraw = new RedrawObject(pi); - _penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pi, RedrawEvent); - _penumbraGetMetaManipulations = new GetPlayerMetaManipulations(pi); - _penumbraRemoveTemporaryMod = new RemoveTemporaryMod(pi); - _penumbraAddTemporaryMod = new AddTemporaryMod(pi); - _penumbraCreateNamedTemporaryCollection = new CreateTemporaryCollection(pi); - _penumbraRemoveTemporaryCollection = new DeleteTemporaryCollection(pi); - _penumbraAssignTemporaryCollection = new AssignTemporaryCollection(pi); - _penumbraGetCollections = new GetCollections(pi); - _penumbraResolvePaths = new ResolvePlayerPathsAsync(pi); - _penumbraEnabled = new GetEnabledState(pi); - _penumbraModSettingChanged = ModSettingChanged.Subscriber(pi, (change, arg1, arg, b) => - { - if (change == ModSettingChange.EnableState) - _lightlessMediator.Publish(new PenumbraModSettingChangedMessage()); - }); - _penumbraConvertTextureFile = new ConvertTextureFile(pi); - _penumbraResourcePaths = new GetGameObjectResourcePaths(pi); - //_penumbraPlayerResourcePaths = new GetPlayerResourcePaths(pi); + _penumbraEnabled = new GetEnabledState(pluginInterface); + _penumbraGetModDirectory = new GetModDirectory(pluginInterface); + _penumbraInit = Initialized.Subscriber(pluginInterface, HandlePenumbraInitialized); + _penumbraDispose = Disposed.Subscriber(pluginInterface, HandlePenumbraDisposed); + _penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged); - _penumbraGameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pi, ResourceLoaded); + _collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator)); + _resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService)); + _redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager)); + _textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw)); + + SubscribeMediatorEvents(); CheckAPI(); CheckModDirectory(); - - Mediator.Subscribe(this, (msg) => - { - _penumbraRedraw.Invoke(msg.Character.ObjectIndex, RedrawType.AfterGPose); - }); - - Mediator.Subscribe(this, (msg) => _shownPenumbraUnavailable = false); - - Mediator.Subscribe(this, msg => - { - if (msg.Descriptor.Address != nint.Zero) - { - _trackedActors[(IntPtr)msg.Descriptor.Address] = 0; - } - }); - - Mediator.Subscribe(this, msg => - { - if (msg.Descriptor.Address != nint.Zero) - { - _trackedActors.TryRemove((IntPtr)msg.Descriptor.Address, out _); - } - }); - - Mediator.Subscribe(this, msg => - { - if (msg.GameObjectHandler.Address != nint.Zero) - { - _trackedActors[(IntPtr)msg.GameObjectHandler.Address] = 0; - } - }); - - Mediator.Subscribe(this, msg => - { - if (msg.GameObjectHandler.Address != nint.Zero) - { - _trackedActors.TryRemove((IntPtr)msg.GameObjectHandler.Address, out _); - } - }); - - foreach (var descriptor in _actorObjectService.PlayerDescriptors) - { - if (descriptor.Address != nint.Zero) - { - _trackedActors[(IntPtr)descriptor.Address] = 0; - } - } } - public bool APIAvailable { get; private set; } = false; - - public void CheckAPI() + public string? ModDirectory { - bool penumbraAvailable = false; - try + get => _modDirectory; + private set { - var penumbraVersion = (_pi.InstalledPlugins - .FirstOrDefault(p => string.Equals(p.InternalName, "Penumbra", StringComparison.OrdinalIgnoreCase)) - ?.Version ?? new Version(0, 0, 0, 0)); - penumbraAvailable = penumbraVersion >= new Version(1, 2, 0, 22); - try + if (string.Equals(_modDirectory, value, StringComparison.Ordinal)) { - penumbraAvailable &= _penumbraEnabled.Invoke(); + return; } - catch - { - penumbraAvailable = false; - } - _shownPenumbraUnavailable = _shownPenumbraUnavailable && !penumbraAvailable; - APIAvailable = penumbraAvailable; - } - catch - { - APIAvailable = penumbraAvailable; - } - finally - { - if (!penumbraAvailable && !_shownPenumbraUnavailable) - { - _shownPenumbraUnavailable = true; - _lightlessMediator.Publish(new NotificationMessage("Penumbra inactive", - "Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.", - NotificationType.Error)); - } - } - if (APIAvailable) - { - ScheduleTemporaryCollectionCleanup(); + _modDirectory = value; + Mediator.Publish(new PenumbraDirectoryChangedMessage(_modDirectory)); } } + public Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex) + => _collections.AssignTemporaryCollectionAsync(logger, collectionId, objectIndex); + + public Task CreateTemporaryCollectionAsync(ILogger logger, string uid) + => _collections.CreateTemporaryCollectionAsync(logger, uid); + + public Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId) + => _collections.RemoveTemporaryCollectionAsync(logger, applicationId, collectionId); + + public Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, Dictionary modPaths) + => _collections.SetTemporaryModsAsync(logger, applicationId, collectionId, modPaths); + + public Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData) + => _collections.SetManipulationDataAsync(logger, applicationId, collectionId, manipulationData); + + public Task>?> GetCharacterData(ILogger logger, GameObjectHandler handler) + => _resources.GetCharacterDataAsync(logger, handler); + + public string GetMetaManipulations() + => _resources.GetMetaManipulations(); + + public Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) + => _resources.ResolvePathsAsync(forward, reverse); + + public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) + => _redraw.RedrawAsync(logger, handler, applicationId, token); + + public Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) + => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token); + + public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) + => _textures.ConvertTextureFileDirectAsync(job, token); + public void CheckModDirectory() { if (!APIAvailable) { ModDirectory = string.Empty; - } - else - { - ModDirectory = _penumbraResolveModDir!.Invoke().ToLowerInvariant(); - } - } - - private void ScheduleTemporaryCollectionCleanup() - { - if (Interlocked.Exchange(ref _performedInitialCleanup, 1) != 0) - return; - - _ = Task.Run(CleanupTemporaryCollectionsAsync); - } - - private async Task CleanupTemporaryCollectionsAsync() - { - if (!APIAvailable) return; + } try { - var collections = await _dalamudUtil.RunOnFrameworkThread(() => _penumbraGetCollections.Invoke()).ConfigureAwait(false); - foreach (var (collectionId, name) in collections) - { - if (!IsLightlessCollectionName(name)) - continue; - - if (_activeTemporaryCollections.ContainsKey(collectionId)) - continue; - - Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); - var deleteResult = await _dalamudUtil.RunOnFrameworkThread(() => - { - var result = (PenumbraApiEc)_penumbraRemoveTemporaryCollection.Invoke(collectionId); - Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); - return result; - }).ConfigureAwait(false); - if (deleteResult == PenumbraApiEc.Success) - { - _activeTemporaryCollections.TryRemove(collectionId, out _); - } - else - { - Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); - } - } + ModDirectory = _penumbraGetModDirectory.Invoke().ToLowerInvariant(); } catch (Exception ex) { - Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); + Logger.LogWarning(ex, "Failed to resolve Penumbra mod directory"); } } - private static bool IsLightlessCollectionName(string? name) - => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); + protected override bool IsPluginEnabled() + { + try + { + return _penumbraEnabled.Invoke(); + } + catch + { + return false; + } + } + + protected override void OnConnectionStateChanged(IpcConnectionState previous, IpcConnectionState current) + { + base.OnConnectionStateChanged(previous, current); + + if (current == IpcConnectionState.Available) + { + _shownPenumbraUnavailable = false; + if (string.IsNullOrEmpty(ModDirectory)) + { + CheckModDirectory(); + } + return; + } + + ModDirectory = string.Empty; + _redraw.CancelPendingRedraws(); + + if (_shownPenumbraUnavailable || current == IpcConnectionState.Unknown) + { + return; + } + + _shownPenumbraUnavailable = true; + Mediator.Publish(new NotificationMessage( + "Penumbra inactive", + "Your Penumbra installation is not active or out of date. Update Penumbra and/or the Enable Mods setting in Penumbra to continue to use Lightless. If you just updated Penumbra, ignore this message.", + NotificationType.Error)); + } + + private void SubscribeMediatorEvents() + { + Mediator.Subscribe(this, msg => + { + _redraw.RequestImmediateRedraw(msg.Character.ObjectIndex, RedrawType.AfterGPose); + }); + + Mediator.Subscribe(this, _ => _shownPenumbraUnavailable = false); + + Mediator.Subscribe(this, msg => _resources.TrackActor(msg.Descriptor.Address)); + Mediator.Subscribe(this, msg => _resources.UntrackActor(msg.Descriptor.Address)); + Mediator.Subscribe(this, msg => _resources.TrackActor(msg.GameObjectHandler.Address)); + Mediator.Subscribe(this, msg => _resources.UntrackActor(msg.GameObjectHandler.Address)); + } + + private void HandlePenumbraInitialized() + { + Mediator.Publish(new PenumbraInitializedMessage()); + CheckModDirectory(); + _redraw.RequestImmediateRedraw(0, RedrawType.Redraw); + CheckAPI(); + } + + private void HandlePenumbraDisposed() + { + _redraw.CancelPendingRedraws(); + ModDirectory = string.Empty; + Mediator.Publish(new PenumbraDisposedMessage()); + CheckAPI(); + } + + private void HandlePenumbraModSettingChanged(ModSettingChange change, Guid _, string __, bool ___) + { + if (change == ModSettingChange.EnableState) + { + Mediator.Publish(new PenumbraModSettingChangedMessage()); + CheckAPI(); + } + } protected override void Dispose(bool disposing) { base.Dispose(disposing); - _redrawManager.Cancel(); + if (!disposing) + { + return; + } _penumbraModSettingChanged.Dispose(); - _penumbraGameObjectResourcePathResolved.Dispose(); _penumbraDispose.Dispose(); _penumbraInit.Dispose(); - _penumbraObjectIsRedrawn.Dispose(); - } - - public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collName, int idx) - { - if (!APIAvailable) return; - - await _dalamudUtil.RunOnFrameworkThread(() => - { - var retAssign = _penumbraAssignTemporaryCollection.Invoke(collName, idx, forceAssignment: true); - logger.LogTrace("Assigning Temp Collection {collName} to index {idx}, Success: {ret}", collName, idx, retAssign); - return collName; - }).ConfigureAwait(false); - } - - public async Task ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) - { - if (!APIAvailable || jobs.Count == 0) - { - return; - } - - _lightlessMediator.Publish(new HaltScanMessage(nameof(ConvertTextureFiles))); - - var totalJobs = jobs.Count; - var completedJobs = 0; - - try - { - foreach (var job in jobs) - { - if (token.IsCancellationRequested) - { - break; - } - - progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job)); - - logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType); - var convertTask = _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps); - await convertTask.ConfigureAwait(false); - - if (convertTask.IsCompletedSuccessfully && job.DuplicateTargets is { Count: > 0 }) - { - foreach (var duplicate in job.DuplicateTargets) - { - logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate); - try - { - File.Copy(job.OutputFile, duplicate, overwrite: true); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate); - } - } - } - - completedJobs++; - } - } - finally - { - _lightlessMediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFiles))); - } - - if (completedJobs > 0 && !token.IsCancellationRequested) - { - await _dalamudUtil.RunOnFrameworkThread(async () => - { - var player = await _dalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false); - if (player == null) - { - return; - } - - var gameObject = await _dalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false); - _penumbraRedraw.Invoke(gameObject!.ObjectIndex, setting: RedrawType.Redraw); - }).ConfigureAwait(false); - } - } - - public async Task CreateTemporaryCollectionAsync(ILogger logger, string uid) - { - if (!APIAvailable) return Guid.Empty; - - var (collectionId, collectionName) = await _dalamudUtil.RunOnFrameworkThread(() => - { - var collName = "Lightless_" + uid; - _penumbraCreateNamedTemporaryCollection.Invoke(collName, collName, out var collId); - logger.LogTrace("Creating Temp Collection {collName}, GUID: {collId}", collName, collId); - return (collId, collName); - - }).ConfigureAwait(false); - if (collectionId != Guid.Empty) - { - _activeTemporaryCollections[collectionId] = collectionName; - } - - return collectionId; - } - - public async Task>?> GetCharacterData(ILogger logger, GameObjectHandler handler) - { - if (!APIAvailable) return null; - - return await _dalamudUtil.RunOnFrameworkThread(() => - { - logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths"); - var idx = handler.GetGameObject()?.ObjectIndex; - if (idx == null) return null; - return _penumbraResourcePaths.Invoke(idx.Value)[0]; - }).ConfigureAwait(false); - } - - public string GetMetaManipulations() - { - if (!APIAvailable) return string.Empty; - return _penumbraGetMetaManipulations.Invoke(); - } - - public async Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) - { - if (!APIAvailable || _dalamudUtil.IsZoning) return; - try - { - await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false); - await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, (chara) => - { - logger.LogDebug("[{appid}] Calling on IPC: PenumbraRedraw", applicationId); - _penumbraRedraw!.Invoke(chara.ObjectIndex, setting: RedrawType.Redraw); - - }, token).ConfigureAwait(false); - } - finally - { - _redrawManager.RedrawSemaphore.Release(); - } - } - - public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collId) - { - if (!APIAvailable) return; - await _dalamudUtil.RunOnFrameworkThread(() => - { - logger.LogTrace("[{applicationId}] Removing temp collection for {collId}", applicationId, collId); - var ret2 = _penumbraRemoveTemporaryCollection.Invoke(collId); - logger.LogTrace("[{applicationId}] RemoveTemporaryCollection: {ret2}", applicationId, ret2); - }).ConfigureAwait(false); - if (collId != Guid.Empty) - { - _activeTemporaryCollections.TryRemove(collId, out _); - } - } - - public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forward, string[] reverse) - { - return await _penumbraResolvePaths.Invoke(forward, reverse).ConfigureAwait(false); - } - - public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) - { - if (!APIAvailable) return; - - token.ThrowIfCancellationRequested(); - - await _penumbraConvertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps) - .ConfigureAwait(false); - - if (job.DuplicateTargets is { Count: > 0 }) - { - foreach (var duplicate in job.DuplicateTargets) - { - try - { - File.Copy(job.OutputFile, duplicate, overwrite: true); - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Failed to copy duplicate {Duplicate} for texture conversion", duplicate); - } - } - } - } - - public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collId, string manipulationData) - { - if (!APIAvailable) return; - - await _dalamudUtil.RunOnFrameworkThread(() => - { - logger.LogTrace("[{applicationId}] Manip: {data}", applicationId, manipulationData); - var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Meta", collId, [], manipulationData, 0); - logger.LogTrace("[{applicationId}] Setting temp meta mod for {collId}, Success: {ret}", applicationId, collId, retAdd); - }).ConfigureAwait(false); - } - - public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collId, Dictionary modPaths) - { - if (!APIAvailable) return; - - await _dalamudUtil.RunOnFrameworkThread(() => - { - foreach (var mod in modPaths) - { - logger.LogTrace("[{applicationId}] Change: {from} => {to}", applicationId, mod.Key, mod.Value); - } - var retRemove = _penumbraRemoveTemporaryMod.Invoke("LightlessChara_Files", collId, 0); - logger.LogTrace("[{applicationId}] Removing temp files mod for {collId}, Success: {ret}", applicationId, collId, retRemove); - var retAdd = _penumbraAddTemporaryMod.Invoke("LightlessChara_Files", collId, modPaths, string.Empty, 0); - logger.LogTrace("[{applicationId}] Setting temp files mod for {collId}, Success: {ret}", applicationId, collId, retAdd); - }).ConfigureAwait(false); - } - - private void RedrawEvent(IntPtr objectAddress, int objectTableIndex) - { - bool wasRequested = false; - if (_penumbraRedrawRequests.TryGetValue(objectAddress, out var redrawRequest) && redrawRequest) - { - _penumbraRedrawRequests[objectAddress] = false; - } - else - { - _lightlessMediator.Publish(new PenumbraRedrawMessage(objectAddress, objectTableIndex, wasRequested)); - } - } - - private void ResourceLoaded(IntPtr ptr, string arg1, string arg2) - { - if (ptr == IntPtr.Zero) - return; - - if (!_trackedActors.ContainsKey(ptr)) - { - var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr); - if (descriptor.Address != nint.Zero) - { - _trackedActors[ptr] = 0; - } - else - { - return; - } - } - - if (string.Compare(arg1, arg2, ignoreCase: true, System.Globalization.CultureInfo.InvariantCulture) == 0) - return; - - _lightlessMediator.Publish(new PenumbraResourceLoadMessage(ptr, arg1, arg2)); - } - - private void PenumbraDispose() - { - _redrawManager.Cancel(); - _lightlessMediator.Publish(new PenumbraDisposedMessage()); - } - - private void PenumbraInit() - { - APIAvailable = true; - ModDirectory = _penumbraResolveModDir.Invoke(); - _lightlessMediator.Publish(new PenumbraInitializedMessage()); - ScheduleTemporaryCollectionCleanup(); - _penumbraRedraw!.Invoke(0, setting: RedrawType.Redraw); } } diff --git a/LightlessSync/Interop/Ipc/IpcCallerPetNames.cs b/LightlessSync/Interop/Ipc/IpcCallerPetNames.cs index 9839f29..5d7fea9 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPetNames.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPetNames.cs @@ -1,14 +1,17 @@ using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; namespace LightlessSync.Interop.Ipc; -public sealed class IpcCallerPetNames : IIpcCaller +public sealed class IpcCallerPetNames : IpcServiceBase { + private static readonly IpcServiceDescriptor PetRenamerDescriptor = new("PetRenamer", "Pet Renamer", new Version(0, 0, 0, 0)); + private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtil; private readonly LightlessMediator _lightlessMediator; @@ -24,7 +27,7 @@ public sealed class IpcCallerPetNames : IIpcCaller private readonly ICallGateSubscriber _clearPlayerData; public IpcCallerPetNames(ILogger logger, IDalamudPluginInterface pi, DalamudUtilService dalamudUtil, - LightlessMediator lightlessMediator) + LightlessMediator lightlessMediator) : base(logger, lightlessMediator, pi, PetRenamerDescriptor) { _logger = logger; _dalamudUtil = dalamudUtil; @@ -46,25 +49,6 @@ public sealed class IpcCallerPetNames : IIpcCaller CheckAPI(); } - - public bool APIAvailable { get; private set; } = false; - - public void CheckAPI() - { - try - { - APIAvailable = _enabled?.InvokeFunc() ?? false; - if (APIAvailable) - { - APIAvailable = _apiVersion?.InvokeFunc() is { Item1: 4, Item2: >= 0 }; - } - } - catch - { - APIAvailable = false; - } - } - private void OnPetNicknamesReady() { CheckAPI(); @@ -76,6 +60,34 @@ public sealed class IpcCallerPetNames : IIpcCaller _lightlessMediator.Publish(new PetNamesMessage(string.Empty)); } + protected override IpcConnectionState EvaluateState() + { + var state = base.EvaluateState(); + if (state != IpcConnectionState.Available) + { + return state; + } + + try + { + var enabled = _enabled?.InvokeFunc() ?? false; + if (!enabled) + { + return IpcConnectionState.PluginDisabled; + } + + var version = _apiVersion?.InvokeFunc() ?? (0u, 0u); + return version.Item1 == 4 && version.Item2 >= 0 + ? IpcConnectionState.Available + : IpcConnectionState.VersionMismatch; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to query Pet Renamer API version"); + return IpcConnectionState.Error; + } + } + public string GetLocalNames() { if (!APIAvailable) return string.Empty; @@ -149,8 +161,14 @@ public sealed class IpcCallerPetNames : IIpcCaller _lightlessMediator.Publish(new PetNamesMessage(data)); } - public void Dispose() + protected override void Dispose(bool disposing) { + base.Dispose(disposing); + if (!disposing) + { + return; + } + _petnamesReady.Unsubscribe(OnPetNicknamesReady); _petnamesDisposing.Unsubscribe(OnPetNicknamesDispose); _playerDataChanged.Unsubscribe(OnLocalPetNicknamesDataChange); diff --git a/LightlessSync/Interop/Ipc/IpcProvider.cs b/LightlessSync/Interop/Ipc/IpcProvider.cs index 88e0202..77f8043 100644 --- a/LightlessSync/Interop/Ipc/IpcProvider.cs +++ b/LightlessSync/Interop/Ipc/IpcProvider.cs @@ -1,4 +1,5 @@ -using Dalamud.Game.ClientState.Objects.Types; +using System; +using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using LightlessSync.PlayerData.Handlers; @@ -14,9 +15,7 @@ public class IpcProvider : IHostedService, IMediatorSubscriber private readonly ILogger _logger; private readonly IDalamudPluginInterface _pi; private readonly CharaDataManager _charaDataManager; - private ICallGateProvider? _loadFileProvider; - private ICallGateProvider>? _loadFileAsyncProvider; - private ICallGateProvider>? _handledGameAddresses; + private readonly List _ipcRegisters = []; private readonly List _activeGameObjectHandlers = []; public LightlessMediator Mediator { get; init; } @@ -44,12 +43,9 @@ public class IpcProvider : IHostedService, IMediatorSubscriber public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting IpcProviderService"); - _loadFileProvider = _pi.GetIpcProvider("LightlessSync.LoadMcdf"); - _loadFileProvider.RegisterFunc(LoadMcdf); - _loadFileAsyncProvider = _pi.GetIpcProvider>("LightlessSync.LoadMcdfAsync"); - _loadFileAsyncProvider.RegisterFunc(LoadMcdfAsync); - _handledGameAddresses = _pi.GetIpcProvider>("LightlessSync.GetHandledAddresses"); - _handledGameAddresses.RegisterFunc(GetHandledAddresses); + _ipcRegisters.Add(RegisterFunc("LightlessSync.LoadMcdf", LoadMcdf)); + _ipcRegisters.Add(RegisterFunc>("LightlessSync.LoadMcdfAsync", LoadMcdfAsync)); + _ipcRegisters.Add(RegisterFunc("LightlessSync.GetHandledAddresses", GetHandledAddresses)); _logger.LogInformation("Started IpcProviderService"); return Task.CompletedTask; } @@ -57,9 +53,11 @@ public class IpcProvider : IHostedService, IMediatorSubscriber public Task StopAsync(CancellationToken cancellationToken) { _logger.LogDebug("Stopping IpcProvider Service"); - _loadFileProvider?.UnregisterFunc(); - _loadFileAsyncProvider?.UnregisterFunc(); - _handledGameAddresses?.UnregisterFunc(); + foreach (var register in _ipcRegisters) + { + register.Dispose(); + } + _ipcRegisters.Clear(); Mediator.UnsubscribeAll(this); return Task.CompletedTask; } @@ -89,4 +87,40 @@ public class IpcProvider : IHostedService, IMediatorSubscriber { return _activeGameObjectHandlers.Where(g => g.Address != nint.Zero).Select(g => g.Address).Distinct().ToList(); } + + private IpcRegister RegisterFunc(string label, Func> handler) + { + var provider = _pi.GetIpcProvider>(label); + provider.RegisterFunc(handler); + return new IpcRegister(provider.UnregisterFunc); + } + + private IpcRegister RegisterFunc(string label, Func handler) + { + var provider = _pi.GetIpcProvider(label); + provider.RegisterFunc(handler); + return new IpcRegister(provider.UnregisterFunc); + } + + private sealed class IpcRegister : IDisposable + { + private readonly Action _unregister; + private bool _disposed; + + public IpcRegister(Action unregister) + { + _unregister = unregister; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _unregister(); + _disposed = true; + } + } } diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs new file mode 100644 index 0000000..4f7b000 --- /dev/null +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraBase.cs @@ -0,0 +1,27 @@ +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.Interop.Ipc.Penumbra; + +public abstract class PenumbraBase : IpcInteropBase +{ + protected PenumbraBase( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator) : base(logger) + { + PluginInterface = pluginInterface; + DalamudUtil = dalamudUtil; + Mediator = mediator; + } + + protected IDalamudPluginInterface PluginInterface { get; } + + protected DalamudUtilService DalamudUtil { get; } + + protected LightlessMediator Mediator { get; } +} diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs new file mode 100644 index 0000000..e5c28e2 --- /dev/null +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraCollections.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Enums; +using Penumbra.Api.IpcSubscribers; + +namespace LightlessSync.Interop.Ipc.Penumbra; + +public sealed class PenumbraCollections : PenumbraBase +{ + private readonly CreateTemporaryCollection _createNamedTemporaryCollection; + private readonly AssignTemporaryCollection _assignTemporaryCollection; + private readonly DeleteTemporaryCollection _removeTemporaryCollection; + private readonly AddTemporaryMod _addTemporaryMod; + private readonly RemoveTemporaryMod _removeTemporaryMod; + private readonly GetCollections _getCollections; + private readonly ConcurrentDictionary _activeTemporaryCollections = new(); + + private int _cleanupScheduled; + + public PenumbraCollections( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator) + { + _createNamedTemporaryCollection = new CreateTemporaryCollection(pluginInterface); + _assignTemporaryCollection = new AssignTemporaryCollection(pluginInterface); + _removeTemporaryCollection = new DeleteTemporaryCollection(pluginInterface); + _addTemporaryMod = new AddTemporaryMod(pluginInterface); + _removeTemporaryMod = new RemoveTemporaryMod(pluginInterface); + _getCollections = new GetCollections(pluginInterface); + } + + public override string Name => "Penumbra.Collections"; + + public async Task AssignTemporaryCollectionAsync(ILogger logger, Guid collectionId, int objectIndex) + { + if (!IsAvailable || collectionId == Guid.Empty) + { + return; + } + + await DalamudUtil.RunOnFrameworkThread(() => + { + var result = _assignTemporaryCollection.Invoke(collectionId, objectIndex, forceAssignment: true); + logger.LogTrace("Assigning Temp Collection {CollectionId} to index {ObjectIndex}, Success: {Result}", collectionId, objectIndex, result); + return result; + }).ConfigureAwait(false); + } + + public async Task CreateTemporaryCollectionAsync(ILogger logger, string uid) + { + if (!IsAvailable) + { + return Guid.Empty; + } + + var (collectionId, collectionName) = await DalamudUtil.RunOnFrameworkThread(() => + { + var name = $"Lightless_{uid}"; + _createNamedTemporaryCollection.Invoke(name, name, out var tempCollectionId); + logger.LogTrace("Creating Temp Collection {CollectionName}, GUID: {CollectionId}", name, tempCollectionId); + return (tempCollectionId, name); + }).ConfigureAwait(false); + + if (collectionId != Guid.Empty) + { + _activeTemporaryCollections[collectionId] = collectionName; + } + + return collectionId; + } + + public async Task RemoveTemporaryCollectionAsync(ILogger logger, Guid applicationId, Guid collectionId) + { + if (!IsAvailable || collectionId == Guid.Empty) + { + return; + } + + await DalamudUtil.RunOnFrameworkThread(() => + { + logger.LogTrace("[{ApplicationId}] Removing temp collection for {CollectionId}", applicationId, collectionId); + var result = _removeTemporaryCollection.Invoke(collectionId); + logger.LogTrace("[{ApplicationId}] RemoveTemporaryCollection: {Result}", applicationId, result); + }).ConfigureAwait(false); + + _activeTemporaryCollections.TryRemove(collectionId, out _); + } + + public async Task SetTemporaryModsAsync(ILogger logger, Guid applicationId, Guid collectionId, IReadOnlyDictionary modPaths) + { + if (!IsAvailable || collectionId == Guid.Empty) + { + return; + } + + await DalamudUtil.RunOnFrameworkThread(() => + { + foreach (var mod in modPaths) + { + logger.LogTrace("[{ApplicationId}] Change: {From} => {To}", applicationId, mod.Key, mod.Value); + } + + var removeResult = _removeTemporaryMod.Invoke("LightlessChara_Files", collectionId, 0); + logger.LogTrace("[{ApplicationId}] Removing temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, removeResult); + + var addResult = _addTemporaryMod.Invoke("LightlessChara_Files", collectionId, new Dictionary(modPaths), string.Empty, 0); + logger.LogTrace("[{ApplicationId}] Setting temp files mod for {CollectionId}, Success: {Result}", applicationId, collectionId, addResult); + }).ConfigureAwait(false); + } + + public async Task SetManipulationDataAsync(ILogger logger, Guid applicationId, Guid collectionId, string manipulationData) + { + if (!IsAvailable || collectionId == Guid.Empty) + { + return; + } + + await DalamudUtil.RunOnFrameworkThread(() => + { + logger.LogTrace("[{ApplicationId}] Manip: {Data}", applicationId, manipulationData); + var result = _addTemporaryMod.Invoke("LightlessChara_Meta", collectionId, [], manipulationData, 0); + logger.LogTrace("[{ApplicationId}] Setting temp meta mod for {CollectionId}, Success: {Result}", applicationId, collectionId, result); + }).ConfigureAwait(false); + } + + protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) + { + if (current == IpcConnectionState.Available) + { + ScheduleCleanup(); + } + else if (previous == IpcConnectionState.Available && current != IpcConnectionState.Available) + { + Interlocked.Exchange(ref _cleanupScheduled, 0); + } + } + + private void ScheduleCleanup() + { + if (Interlocked.Exchange(ref _cleanupScheduled, 1) != 0) + { + return; + } + + _ = Task.Run(CleanupTemporaryCollectionsAsync); + } + + private async Task CleanupTemporaryCollectionsAsync() + { + if (!IsAvailable) + { + return; + } + + try + { + var collections = await DalamudUtil.RunOnFrameworkThread(() => _getCollections.Invoke()).ConfigureAwait(false); + foreach (var (collectionId, name) in collections) + { + if (!IsLightlessCollectionName(name) || _activeTemporaryCollections.ContainsKey(collectionId)) + { + continue; + } + + Logger.LogDebug("Cleaning up stale temporary collection {CollectionName} ({CollectionId})", name, collectionId); + var deleteResult = await DalamudUtil.RunOnFrameworkThread(() => + { + var result = (PenumbraApiEc)_removeTemporaryCollection.Invoke(collectionId); + Logger.LogTrace("Cleanup RemoveTemporaryCollection result for {CollectionName} ({CollectionId}): {Result}", name, collectionId, result); + return result; + }).ConfigureAwait(false); + + if (deleteResult == PenumbraApiEc.Success) + { + _activeTemporaryCollections.TryRemove(collectionId, out _); + } + else + { + Logger.LogDebug("Skipped removing temporary collection {CollectionName} ({CollectionId}). Result: {Result}", name, collectionId, deleteResult); + } + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to clean up Penumbra temporary collections"); + } + } + + private static bool IsLightlessCollectionName(string? name) + => !string.IsNullOrEmpty(name) && name.StartsWith("Lightless_", StringComparison.Ordinal); +} diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs new file mode 100644 index 0000000..7b3abd1 --- /dev/null +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs @@ -0,0 +1,81 @@ +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.PlayerData.Handlers; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Enums; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; + +namespace LightlessSync.Interop.Ipc.Penumbra; + +public sealed class PenumbraRedraw : PenumbraBase +{ + private readonly RedrawManager _redrawManager; + private readonly RedrawObject _penumbraRedraw; + private readonly EventSubscriber _penumbraObjectIsRedrawn; + + public PenumbraRedraw( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator, + RedrawManager redrawManager) : base(logger, pluginInterface, dalamudUtil, mediator) + { + _redrawManager = redrawManager; + + _penumbraRedraw = new RedrawObject(pluginInterface); + _penumbraObjectIsRedrawn = GameObjectRedrawn.Subscriber(pluginInterface, HandlePenumbraRedrawEvent); + } + + public override string Name => "Penumbra.Redraw"; + + public void CancelPendingRedraws() + => _redrawManager.Cancel(); + + public void RequestImmediateRedraw(int objectIndex, RedrawType redrawType) + { + if (!IsAvailable) + { + return; + } + + _penumbraRedraw.Invoke(objectIndex, redrawType); + } + + public async Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token) + { + if (!IsAvailable || DalamudUtil.IsZoning) + { + return; + } + + try + { + await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false); + await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, chara => + { + logger.LogDebug("[{ApplicationId}] Calling on IPC: PenumbraRedraw", applicationId); + _penumbraRedraw.Invoke(chara.ObjectIndex, RedrawType.Redraw); + }, token).ConfigureAwait(false); + } + finally + { + _redrawManager.RedrawSemaphore.Release(); + } + } + + private void HandlePenumbraRedrawEvent(IntPtr objectAddress, int objectTableIndex) + => Mediator.Publish(new PenumbraRedrawMessage(objectAddress, objectTableIndex, false)); + + protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) + { + } + + public override void Dispose() + { + base.Dispose(); + _penumbraObjectIsRedrawn.Dispose(); + } +} diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs new file mode 100644 index 0000000..75d1d86 --- /dev/null +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -0,0 +1,141 @@ +using System.Collections.Concurrent; +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.PlayerData.Handlers; +using LightlessSync.Services; +using LightlessSync.Services.ActorTracking; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Helpers; +using Penumbra.Api.IpcSubscribers; + +namespace LightlessSync.Interop.Ipc.Penumbra; + +public sealed class PenumbraResource : PenumbraBase +{ + private readonly ActorObjectService _actorObjectService; + private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; + private readonly ResolvePlayerPathsAsync _resolvePlayerPaths; + private readonly GetPlayerMetaManipulations _getPlayerMetaManipulations; + private readonly EventSubscriber _gameObjectResourcePathResolved; + private readonly ConcurrentDictionary _trackedActors = new(); + + public PenumbraResource( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator, + ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator) + { + _actorObjectService = actorObjectService; + _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); + _resolvePlayerPaths = new ResolvePlayerPathsAsync(pluginInterface); + _getPlayerMetaManipulations = new GetPlayerMetaManipulations(pluginInterface); + _gameObjectResourcePathResolved = GameObjectResourcePathResolved.Subscriber(pluginInterface, HandleResourceLoaded); + + foreach (var descriptor in _actorObjectService.PlayerDescriptors) + { + TrackActor(descriptor.Address); + } + } + + public override string Name => "Penumbra.Resources"; + + public async Task>?> GetCharacterDataAsync(ILogger logger, GameObjectHandler handler) + { + if (!IsAvailable) + { + return null; + } + + return await DalamudUtil.RunOnFrameworkThread(() => + { + logger.LogTrace("Calling On IPC: Penumbra.GetGameObjectResourcePaths"); + var idx = handler.GetGameObject()?.ObjectIndex; + if (idx == null) + { + return null; + } + + return _gameObjectResourcePaths.Invoke(idx.Value)[0]; + }).ConfigureAwait(false); + } + + public string GetMetaManipulations() + => IsAvailable ? _getPlayerMetaManipulations.Invoke() : string.Empty; + + public async Task<(string[] forward, string[][] reverse)> ResolvePathsAsync(string[] forwardPaths, string[] reversePaths) + { + if (!IsAvailable) + { + return (Array.Empty(), Array.Empty()); + } + + return await _resolvePlayerPaths.Invoke(forwardPaths, reversePaths).ConfigureAwait(false); + } + + public void TrackActor(nint address) + { + if (address != nint.Zero) + { + _trackedActors[(IntPtr)address] = 0; + } + } + + public void UntrackActor(nint address) + { + if (address != nint.Zero) + { + _trackedActors.TryRemove((IntPtr)address, out _); + } + } + + private void HandleResourceLoaded(nint ptr, string resolvedPath, string gamePath) + { + if (ptr == nint.Zero) + { + return; + } + + if (!_trackedActors.ContainsKey(ptr)) + { + var descriptor = _actorObjectService.PlayerDescriptors.FirstOrDefault(d => d.Address == ptr); + if (descriptor.Address != nint.Zero) + { + _trackedActors[ptr] = 0; + } + else + { + return; + } + } + + if (string.Compare(resolvedPath, gamePath, StringComparison.OrdinalIgnoreCase) == 0) + { + return; + } + + Mediator.Publish(new PenumbraResourceLoadMessage(ptr, resolvedPath, gamePath)); + } + + protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) + { + if (current != IpcConnectionState.Available) + { + _trackedActors.Clear(); + } + else + { + foreach (var descriptor in _actorObjectService.PlayerDescriptors) + { + TrackActor(descriptor.Address); + } + } + } + + public override void Dispose() + { + base.Dispose(); + _gameObjectResourcePathResolved.Dispose(); + } +} diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs new file mode 100644 index 0000000..e12fd7b --- /dev/null +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs @@ -0,0 +1,121 @@ +using Dalamud.Plugin; +using LightlessSync.Interop.Ipc.Framework; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using Microsoft.Extensions.Logging; +using Penumbra.Api.Enums; +using Penumbra.Api.IpcSubscribers; + +namespace LightlessSync.Interop.Ipc.Penumbra; + +public sealed class PenumbraTexture : PenumbraBase +{ + private readonly PenumbraRedraw _redrawFeature; + private readonly ConvertTextureFile _convertTextureFile; + + public PenumbraTexture( + ILogger logger, + IDalamudPluginInterface pluginInterface, + DalamudUtilService dalamudUtil, + LightlessMediator mediator, + PenumbraRedraw redrawFeature) : base(logger, pluginInterface, dalamudUtil, mediator) + { + _redrawFeature = redrawFeature; + _convertTextureFile = new ConvertTextureFile(pluginInterface); + } + + public override string Name => "Penumbra.Textures"; + + public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) + { + if (!IsAvailable || jobs.Count == 0) + { + return; + } + + Mediator.Publish(new HaltScanMessage(nameof(ConvertTextureFilesAsync))); + + var totalJobs = jobs.Count; + var completedJobs = 0; + + try + { + foreach (var job in jobs) + { + if (token.IsCancellationRequested) + { + break; + } + + progress?.Report(new TextureConversionProgress(completedJobs, totalJobs, job)); + await ConvertSingleJobAsync(logger, job, token).ConfigureAwait(false); + completedJobs++; + } + } + finally + { + Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync))); + } + + if (completedJobs > 0 && !token.IsCancellationRequested) + { + await DalamudUtil.RunOnFrameworkThread(async () => + { + var player = await DalamudUtil.GetPlayerPointerAsync().ConfigureAwait(false); + if (player == null) + { + return; + } + + var gameObject = await DalamudUtil.CreateGameObjectAsync(player).ConfigureAwait(false); + if (gameObject == null) + { + return; + } + + _redrawFeature.RequestImmediateRedraw(gameObject.ObjectIndex, RedrawType.Redraw); + }).ConfigureAwait(false); + } + } + + public async Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) + { + if (!IsAvailable) + { + return; + } + + await ConvertSingleJobAsync(Logger, job, token).ConfigureAwait(false); + } + + private async Task ConvertSingleJobAsync(ILogger logger, TextureConversionJob job, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + + logger.LogInformation("Converting texture {Input} -> {Output} ({Target})", job.InputFile, job.OutputFile, job.TargetType); + var convertTask = _convertTextureFile.Invoke(job.InputFile, job.OutputFile, job.TargetType, job.IncludeMipMaps); + await convertTask.ConfigureAwait(false); + + if (!convertTask.IsCompletedSuccessfully || job.DuplicateTargets is not { Count: > 0 }) + { + return; + } + + foreach (var duplicate in job.DuplicateTargets) + { + try + { + logger.LogInformation("Synchronizing duplicate {Duplicate}", duplicate); + File.Copy(job.OutputFile, duplicate, overwrite: true); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to copy duplicate {Duplicate}", duplicate); + } + } + } + + protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) + { + } +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 1842989..5136a6e 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -228,7 +228,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new IpcCallerPetNames(s.GetRequiredService>(), pluginInterface, s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerBrio(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton((s) => new IpcManager(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), -- 2.49.1 From 28967d6e17f3f17bddfa480d7b4456394cfba7d7 Mon Sep 17 00:00:00 2001 From: azyges Date: Sat, 29 Nov 2025 09:17:28 +0900 Subject: [PATCH 053/140] rebuild the temp collection if cached files don't persist --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 148 +++++++++++++----- 1 file changed, 106 insertions(+), 42 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index d08cc19..ad77bcb 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -77,6 +77,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private CancellationTokenSource? _downloadCancellationTokenSource = new(); private bool _forceApplyMods = false; private bool _forceFullReapply; + private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths; + private bool _needsCollectionRebuild; private bool _isVisible; private Guid _penumbraCollection; private readonly object _collectionGate = new(); @@ -349,12 +351,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private void ResetPenumbraCollection(bool releaseFromPenumbra = true, string? reason = null) { Guid toRelease = Guid.Empty; + bool hadCollection = false; lock (_collectionGate) { if (_penumbraCollection != Guid.Empty) { toRelease = _penumbraCollection; _penumbraCollection = Guid.Empty; + hadCollection = true; } } @@ -362,6 +366,13 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (cached.HasValue && cached.Value != Guid.Empty) { toRelease = cached.Value; + hadCollection = true; + } + + if (hadCollection) + { + _needsCollectionRebuild = true; + _forceFullReapply = true; } if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable) @@ -600,6 +611,25 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return data; } + private bool HasValidCachedModdedPaths() + { + if (_lastAppliedModdedPaths is null || _lastAppliedModdedPaths.Count == 0) + { + return false; + } + + foreach (var entry in _lastAppliedModdedPaths) + { + if (string.IsNullOrEmpty(entry.Value) || !File.Exists(entry.Value)) + { + Logger.LogDebug("Cached file path {path} missing for {handler}, forcing recalculation", entry.Value ?? "empty", GetLogIdentifier()); + return false; + } + } + + return true; + } + private bool CanApplyNow() { return !_dalamudUtil.IsInCombat @@ -844,6 +874,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { PlayerName = null; _cachedData = null; + _lastAppliedModdedPaths = null; + _needsCollectionRebuild = false; Logger.LogDebug("Disposing {name} complete", name); } } @@ -1012,73 +1044,103 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var updateModdedPaths = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModFiles)); var updateManip = updatedData.Values.Any(v => v.Any(p => p == PlayerChanges.ModManip)); + var needsCollectionRebuild = _needsCollectionRebuild; + var reuseCachedModdedPaths = !updateModdedPaths && needsCollectionRebuild && _lastAppliedModdedPaths is not null; + updateModdedPaths = updateModdedPaths || needsCollectionRebuild; + updateManip = updateManip || needsCollectionRebuild; + Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths = null; + if (reuseCachedModdedPaths) + { + if (HasValidCachedModdedPaths()) + { + cachedModdedPaths = _lastAppliedModdedPaths; + } + else + { + Logger.LogDebug("{handler}: Cached files missing, recalculating mappings", GetLogIdentifier()); + _lastAppliedModdedPaths = null; + } + } _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, downloadToken).ConfigureAwait(false); + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken).ConfigureAwait(false); } private Task? _pairDownloadTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, - bool updateModdedPaths, bool updateManip, CancellationToken downloadToken) + bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) { await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); - Dictionary<(string GamePath, string? Hash), string> moddedPaths = []; bool skipDownscaleForPair = ShouldSkipDownscale(); var user = GetPrimaryUserData(); + Dictionary<(string GamePath, string? Hash), string> moddedPaths; if (updateModdedPaths) { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + if (cachedModdedPaths is not null) { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) + moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer); + } + else + { + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) + { + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); + await _pairDownloadTask.ConfigureAwait(false); + } + + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); + + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) + { + _downloadManager.ClearDownload(); + return; + } + + var handlerForDownload = _charaHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); + await _pairDownloadTask.ConfigureAwait(false); + + if (downloadToken.IsCancellationRequested) + { + Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + return; + } + + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); } - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); - - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) { - _downloadManager.ClearDownload(); return; } - - var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); - - await _pairDownloadTask.ConfigureAwait(false); - - if (downloadToken.IsCancellationRequested) - { - Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - return; - } - - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); - } - - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) - { - return; } } + else + { + moddedPaths = cachedModdedPaths is not null + ? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer) + : []; + } downloadToken.ThrowIfCancellationRequested(); @@ -1162,6 +1224,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, penumbraCollection, moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(moddedPaths, moddedPaths.Comparer); LastAppliedDataBytes = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) { @@ -1187,6 +1250,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = false; + _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); -- 2.49.1 From aa04ab05ab400842b3a24b7ebd5b65f1c9ca3787 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 29 Nov 2025 04:51:53 +0100 Subject: [PATCH 054/140] added downscaled as file scanning if exist. made gib to gb for calculations. changed windows detection. --- LightlessSync/FileCache/CacheMonitor.cs | 48 +++++++++++++++++++++++- LightlessSync/FileCache/FileCompactor.cs | 37 +++++++++++------- LightlessSync/UI/UISharedService.cs | 4 +- LightlessSync/Utils/FileSystemHelper.cs | 6 +-- 4 files changed, 76 insertions(+), 19 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 486e11e..b3f273c 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -469,7 +469,53 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase FileCacheSize = totalSize; - var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); + if (Directory.Exists(_configService.Current.CacheFolder + "/downscaled")) + { + var filesDownscaled = Directory.EnumerateFiles(_configService.Current.CacheFolder + "/downscaled").Select(f => new FileInfo(f)).OrderBy(f => f.LastAccessTime).ToList(); + + long totalSizeDownscaled = 0; + + foreach (var f in filesDownscaled) + { + token.ThrowIfCancellationRequested(); + + try + { + long size = 0; + + if (!isWine) + { + try + { + size = _fileCompactor.GetFileSizeOnDisk(f); + } + catch (Exception ex) + { + Logger.LogTrace(ex, "GetFileSizeOnDisk failed for {file}, using fallback length", f.FullName); + size = f.Length; + } + } + else + { + size = f.Length; + } + + totalSizeDownscaled += size; + } + catch (Exception ex) + { + Logger.LogTrace(ex, "Error getting size for {file}", f.FullName); + } + } + + FileCacheSize = (totalSize + totalSizeDownscaled); + } + else + { + FileCacheSize = totalSize; + } + + var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); if (FileCacheSize < maxCacheInBytes) return; diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 3edf96a..53377b6 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -4,6 +4,7 @@ using LightlessSync.Services.Compactor; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; +using System.ComponentModel; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading.Channels; @@ -16,6 +17,7 @@ public sealed class FileCompactor : IDisposable public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const ulong WOF_PROVIDER_FILE = 2UL; public const int _maxRetries = 3; + private readonly bool _isWindows; private readonly ConcurrentDictionary _pendingCompactions; private readonly ILogger _logger; @@ -61,6 +63,7 @@ public sealed class FileCompactor : IDisposable _logger = logger; _lightlessConfigService = lightlessConfigService; _dalamudUtilService = dalamudUtilService; + _isWindows = OperatingSystem.IsWindows(); _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions { @@ -197,11 +200,10 @@ public sealed class FileCompactor : IDisposable { try { - bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); var (_, linuxPath) = ResolvePathsForBtrfs(fileInfo.FullName); var (ok, output, err, code) = - isWindowsProc + _isWindows ? RunProcessShell($"stat -c='%b' {QuoteSingle(linuxPath)}", workingDir: null, 10000) : RunProcessDirect("stat", ["-c='%b'", linuxPath], workingDir: null, 10000); @@ -228,16 +230,28 @@ public sealed class FileCompactor : IDisposable try { var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); - var losize = GetCompressedFileSizeW(fileInfo.FullName, out uint hosize); - var size = (long)hosize << 32 | losize; - return (flowControl: false, value: ((size + blockSize - 1) / blockSize) * blockSize); + if (blockSize <= 0) + throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}"); + + uint lo = GetCompressedFileSizeW(fileInfo.FullName, out uint hi); + + if (lo == 0xFFFFFFFF) + { + int err = Marshal.GetLastWin32Error(); + if (err != 0) + throw new Win32Exception(err); + } + + long size = ((long)hi << 32) | lo; + long rounded = ((size + blockSize - 1) / blockSize) * blockSize; + + return (flowControl: false, value: rounded); } catch (Exception ex) { _logger.LogDebug(ex, "Failed stat size for {file}, fallback to Length", fileInfo.FullName); + return (flowControl: true, value: default); } - - return (flowControl: true, value: default); } /// @@ -685,7 +699,6 @@ public sealed class FileCompactor : IDisposable try { var (winPath, linuxPath) = ResolvePathsForBtrfs(path); - bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); if (IsBtrfsCompressedFile(linuxPath)) { @@ -700,7 +713,7 @@ public sealed class FileCompactor : IDisposable } (bool ok, string stdout, string stderr, int code) = - isWindowsProc + _isWindows ? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(linuxPath)}") : RunProcessDirect("btrfs", ["filesystem", "defragment", "-clzo", "--", linuxPath]); @@ -1029,9 +1042,7 @@ public sealed class FileCompactor : IDisposable /// private (string windowsPath, string linuxPath) ResolvePathsForBtrfs(string path) { - bool isWindowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - - if (!isWindowsProc) + if (!_isWindows) return (path, path); var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(path)}", workingDir: null, 5000); @@ -1050,7 +1061,7 @@ public sealed class FileCompactor : IDisposable { try { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + if (_isWindows) { using var _ = new FileStream(winePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); } diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 1d1c6b0..95132ec 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -179,9 +179,9 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase int i = 0; double dblSByte = bytes; - while (dblSByte >= 1000 && i < suffix.Length - 1) + while (dblSByte >= 1024 && i < suffix.Length - 1) { - dblSByte /= 1000.0; + dblSByte /= 1024.0; i++; } diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index d63b3b9..f7b3c45 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -32,7 +32,7 @@ namespace LightlessSync.Utils { string rootPath; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) + if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine)) { var info = new FileInfo(filePath); var dir = info.Directory ?? new DirectoryInfo(filePath); @@ -50,7 +50,7 @@ namespace LightlessSync.Utils FilesystemType detected; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && (!IsProbablyWine() || !isWine)) + if (OperatingSystem.IsWindows() && (!IsProbablyWine() || !isWine)) { var root = new DriveInfo(rootPath); var format = root.DriveFormat?.ToUpperInvariant() ?? string.Empty; @@ -214,7 +214,7 @@ namespace LightlessSync.Utils if (_blockSizeCache.TryGetValue(root, out int cached)) return cached; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !isWine) + if (OperatingSystem.IsWindows() && !isWine) { int result = GetDiskFreeSpaceW(root, out uint sectorsPerCluster, -- 2.49.1 From 740b58afc4c6c4cffda07247daaf13c90d6593a6 Mon Sep 17 00:00:00 2001 From: defnotken Date: Sat, 29 Nov 2025 18:02:39 +0100 Subject: [PATCH 055/140] Initialize migration. (#88) Co-authored-by: defnotken Co-authored-by: cake Reviewed-on: https://git.lightless-sync.org/Lightless-Sync/LightlessClient/pulls/88 Reviewed-by: cake Co-authored-by: defnotken Co-committed-by: defnotken --- LightlessSync/FileCache/CacheMonitor.cs | 24 +- LightlessSync/FileCache/FileCacheManager.cs | 191 +++++-- LightlessSync/FileCache/FileCompactor.cs | 454 ++++++++++------- LightlessSync/FileCache/FileState.cs | 1 + LightlessSync/Interop/DalamudLogger.cs | 5 +- LightlessSync/LightlessSync.csproj | 3 +- LightlessSync/Plugin.cs | 4 +- ...gService.cs => BroadcastScannerService.cs} | 54 +- LightlessSync/Services/BroadcastService.cs | 7 +- .../CharaData/CharacterAnalysisSummary.cs | 19 + .../Models/CharacterAnalysisObjectSummary.cs | 8 + LightlessSync/Services/CharacterAnalyzer.cs | 94 ++-- .../Compactor/BatchFileFragService.cs | 28 +- LightlessSync/Services/ContextMenuService.cs | 8 +- LightlessSync/Services/DalamudUtilService.cs | 168 ++++--- .../Services/LightlessProfileManager.cs | 1 + LightlessSync/Services/Mediator/Messages.cs | 2 + LightlessSync/Services/NameplateHandler.cs | 59 ++- LightlessSync/Services/NotificationService.cs | 43 +- .../PairProcessingLimiterSnapshot.cs | 9 + .../Services/PairProcessingLimiter.cs | 21 +- .../Services/PerformanceCollectorService.cs | 6 +- .../LightlessGroupProfileData.cs | 5 +- .../LightlessUserProfileData.cs | 5 +- LightlessSync/Services/UiFactory.cs | 6 +- LightlessSync/Services/XivDataAnalyzer.cs | 13 +- LightlessSync/UI/BroadcastUI.cs | 9 +- LightlessSync/UI/CharaDataHubUi.Functions.cs | 8 +- LightlessSync/UI/CharaDataHubUi.McdOnline.cs | 18 +- LightlessSync/UI/CharaDataHubUi.cs | 6 +- LightlessSync/UI/CompactUI.cs | 6 +- .../UI/Components/DrawFolderGroup.cs | 1 + LightlessSync/UI/DownloadUi.cs | 16 +- LightlessSync/UI/DtrEntry.cs | 4 +- LightlessSync/UI/EditProfileUi.Group.cs | 1 + LightlessSync/UI/EditProfileUi.cs | 7 +- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 6 +- LightlessSync/UI/IntroUI.cs | 6 +- LightlessSync/UI/JoinSyncshellUI.cs | 2 +- LightlessSync/UI/LightlessNotificationUI.cs | 73 +-- LightlessSync/UI/Models/Changelog.cs | 43 -- LightlessSync/UI/Models/ChangelogEntry.cs | 12 + LightlessSync/UI/Models/ChangelogFile.cs | 10 + LightlessSync/UI/Models/ChangelogVersion.cs | 8 + LightlessSync/UI/Models/CreditCategory.cs | 8 + LightlessSync/UI/Models/CreditItem.cs | 8 + LightlessSync/UI/Models/CreditsFile.cs | 7 + .../UI/Models/LightlessNotification.cs | 14 +- .../UI/Models/LightlessNotificationAction.cs | 15 + LightlessSync/UI/Services/PairUiService.cs | 3 - LightlessSync/UI/SettingsUi.cs | 126 +++-- LightlessSync/UI/SyncshellAdminUI.cs | 42 +- LightlessSync/UI/SyncshellFinderUI.cs | 467 ++++++++++++++---- LightlessSync/UI/TopTabMenu.cs | 224 ++++----- LightlessSync/UI/UIColors.cs | 14 +- LightlessSync/UI/UISharedService.cs | 10 +- LightlessSync/UI/UpdateNotesUi.cs | 13 +- LightlessSync/Utils/Crypto.cs | 203 +++++++- LightlessSync/Utils/FileSystemHelper.cs | 37 +- LightlessSync/Utils/SeStringUtils.cs | 11 +- .../WebAPI/Files/FileDownloadManager.cs | 3 +- LightlessSync/WebAPI/SignalR/ApiController.cs | 40 +- LightlessSync/packages.lock.json | 6 + 63 files changed, 1720 insertions(+), 1005 deletions(-) rename LightlessSync/Services/{BroadcastScanningService.cs => BroadcastScannerService.cs} (86%) create mode 100644 LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs create mode 100644 LightlessSync/Services/CharaData/Models/CharacterAnalysisObjectSummary.cs create mode 100644 LightlessSync/Services/PairProcessing/PairProcessingLimiterSnapshot.cs rename LightlessSync/Services/{ => Profiles}/LightlessGroupProfileData.cs (86%) rename LightlessSync/Services/{ => Profiles}/LightlessUserProfileData.cs (88%) delete mode 100644 LightlessSync/UI/Models/Changelog.cs create mode 100644 LightlessSync/UI/Models/ChangelogEntry.cs create mode 100644 LightlessSync/UI/Models/ChangelogFile.cs create mode 100644 LightlessSync/UI/Models/ChangelogVersion.cs create mode 100644 LightlessSync/UI/Models/CreditCategory.cs create mode 100644 LightlessSync/UI/Models/CreditItem.cs create mode 100644 LightlessSync/UI/Models/CreditsFile.cs create mode 100644 LightlessSync/UI/Models/LightlessNotificationAction.cs diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index b3f273c..83c3b96 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -72,7 +72,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase { while (_dalamudUtil.IsOnFrameworkThread && !token.IsCancellationRequested) { - await Task.Delay(1).ConfigureAwait(false); + await Task.Delay(1, token).ConfigureAwait(false); } RecalculateFileCacheSize(token); @@ -101,8 +101,8 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase } record WatcherChange(WatcherChangeTypes ChangeType, string? OldPath = null); - private readonly Dictionary _watcherChanges = new Dictionary(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _lightlessChanges = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _watcherChanges = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _lightlessChanges = new(StringComparer.OrdinalIgnoreCase); public void StopMonitoring() { @@ -128,7 +128,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase } var fsType = FileSystemHelper.GetFilesystemType(_configService.Current.CacheFolder, _dalamudUtil.IsWine); - if (fsType == FileSystemHelper.FilesystemType.NTFS) + if (fsType == FileSystemHelper.FilesystemType.NTFS && !_dalamudUtil.IsWine) { StorageisNTFS = true; Logger.LogInformation("Lightless Storage is on NTFS drive: {isNtfs}", StorageisNTFS); @@ -259,6 +259,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase private CancellationTokenSource _penumbraFswCts = new(); private CancellationTokenSource _lightlessFswCts = new(); + public FileSystemWatcher? PenumbraWatcher { get; private set; } public FileSystemWatcher? LightlessWatcher { get; private set; } @@ -509,13 +510,13 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase } FileCacheSize = (totalSize + totalSizeDownscaled); - } + } else { FileCacheSize = totalSize; } - var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); + var maxCacheInBytes = (long)(_configService.Current.MaxLocalCacheInGiB * 1024d * 1024d * 1024d); if (FileCacheSize < maxCacheInBytes) return; @@ -556,12 +557,19 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase protected override void Dispose(bool disposing) { base.Dispose(disposing); - _scanCancellationTokenSource?.Cancel(); + // Disposing of file system watchers PenumbraWatcher?.Dispose(); LightlessWatcher?.Dispose(); + + // Disposing of cancellation token sources + _scanCancellationTokenSource?.CancelDispose(); + _scanCancellationTokenSource?.Dispose(); _penumbraFswCts?.CancelDispose(); + _penumbraFswCts?.Dispose(); _lightlessFswCts?.CancelDispose(); + _lightlessFswCts?.Dispose(); _periodicCalculationTokenSource?.CancelDispose(); + _periodicCalculationTokenSource?.Dispose(); } private void FullFileScan(CancellationToken ct) @@ -639,7 +647,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase List entitiesToRemove = []; List entitiesToUpdate = []; - object sync = new(); + Lock sync = new(); Thread[] workerThreads = new Thread[threadCount]; ConcurrentQueue fileCaches = new(_fileDbManager.GetAllFileCaches()); diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index cda255c..ff45c6a 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -18,6 +18,7 @@ public sealed class FileCacheManager : IHostedService public const string PenumbraPrefix = "{penumbra}"; private const int FileCacheVersion = 1; private const string FileCacheVersionHeaderPrefix = "#lightless-file-cache-version:"; + private readonly SemaphoreSlim _fileWriteSemaphore = new(1, 1); private readonly LightlessConfigService _configService; private readonly LightlessMediator _lightlessMediator; private readonly string _csvPath; @@ -41,11 +42,8 @@ public sealed class FileCacheManager : IHostedService private string CsvBakPath => _csvPath + ".bak"; - private static string NormalizeSeparators(string path) - { - return path.Replace("/", "\\", StringComparison.Ordinal) + private static string NormalizeSeparators(string path) => path.Replace("/", "\\", StringComparison.Ordinal) .Replace("\\\\", "\\", StringComparison.Ordinal); - } private static string NormalizePrefixedPathKey(string prefixedPath) { @@ -134,13 +132,9 @@ public sealed class FileCacheManager : IHostedService chosenLength = penumbraMatch; } - if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch)) + if (TryBuildPrefixedPath(normalized, _configService.Current.CacheFolder, CachePrefix, out var cachePrefixed, out var cacheMatch) && cacheMatch > chosenLength) { - if (cacheMatch > chosenLength) - { - chosenPrefixed = cachePrefixed; - chosenLength = cacheMatch; - } + chosenPrefixed = cachePrefixed; } return NormalizePrefixedPathKey(chosenPrefixed ?? normalized); @@ -176,27 +170,53 @@ public sealed class FileCacheManager : IHostedService return CreateFileCacheEntity(fi, prefixedPath); } - public List GetAllFileCaches() => _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null)).ToList(); + public List GetAllFileCaches() => [.. _fileCaches.Values.SelectMany(v => v.Values.Where(e => e != null))]; public List GetAllFileCachesByHash(string hash, bool ignoreCacheEntries = false, bool validate = true) { - List output = []; - if (_fileCaches.TryGetValue(hash, out var fileCacheEntities)) + var output = new List(); + + if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities)) + return output; + + foreach (var fileCache in fileCacheEntities.Values + .Where(c => !ignoreCacheEntries || !c.IsCacheEntry)) { - foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry).ToList()) + if (!validate) { - if (!validate) - { - output.Add(fileCache); - } - else - { - var validated = GetValidatedFileCache(fileCache); - if (validated != null) - { - output.Add(validated); - } - } + output.Add(fileCache); + continue; + } + + var validated = GetValidatedFileCache(fileCache); + if (validated != null) + output.Add(validated); + } + + return output; + } + + public async Task> GetAllFileCachesByHashAsync(string hash, bool ignoreCacheEntries = false, bool validate = true,CancellationToken token = default) + { + var output = new List(); + + if (!_fileCaches.TryGetValue(hash, out var fileCacheEntities)) + return output; + + foreach (var fileCache in fileCacheEntities.Values.Where(c => !ignoreCacheEntries || !c.IsCacheEntry)) + { + token.ThrowIfCancellationRequested(); + + if (!validate) + { + output.Add(fileCache); + } + else + { + var validated = await GetValidatedFileCacheAsync(fileCache, token).ConfigureAwait(false); + + if (validated != null) + output.Add(validated); } } @@ -237,11 +257,11 @@ public sealed class FileCacheManager : IHostedService brokenEntities.Add(fileCache); return; } - + var algo = Crypto.DetectAlgo(fileCache.Hash); string computedHash; try { - computedHash = await Crypto.GetFileHashAsync(fileCache.ResolvedFilepath, token).ConfigureAwait(false); + computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, algo, token).ConfigureAwait(false); } catch (Exception ex) { @@ -253,8 +273,8 @@ public sealed class FileCacheManager : IHostedService if (!string.Equals(computedHash, fileCache.Hash, StringComparison.Ordinal)) { _logger.LogInformation( - "Hash mismatch: {file} (got {computedHash}, expected {expected})", - fileCache.ResolvedFilepath, computedHash, fileCache.Hash); + "Hash mismatch: {file} (got {computedHash}, expected {expected} : hash {hash})", + fileCache.ResolvedFilepath, computedHash, fileCache.Hash, algo); brokenEntities.Add(fileCache); } @@ -429,12 +449,13 @@ public sealed class FileCacheManager : IHostedService _logger.LogTrace("Updating hash for {path}", fileCache.ResolvedFilepath); var oldHash = fileCache.Hash; var prefixedPath = fileCache.PrefixedFilePath; + var algo = Crypto.DetectAlgo(fileCache.ResolvedFilepath); if (computeProperties) { var fi = new FileInfo(fileCache.ResolvedFilepath); fileCache.Size = fi.Length; fileCache.CompressedSize = null; - fileCache.Hash = Crypto.GetFileHash(fileCache.ResolvedFilepath); + fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, algo); fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); } RemoveHashedFile(oldHash, prefixedPath); @@ -485,6 +506,44 @@ public sealed class FileCacheManager : IHostedService } } + public async Task WriteOutFullCsvAsync(CancellationToken cancellationToken = default) + { + await _fileWriteSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var sb = new StringBuilder(); + sb.AppendLine(BuildVersionHeader()); + + foreach (var entry in _fileCaches.Values + .SelectMany(k => k.Values) + .OrderBy(f => f.PrefixedFilePath, StringComparer.OrdinalIgnoreCase)) + { + sb.AppendLine(entry.CsvEntry); + } + + if (File.Exists(_csvPath)) + { + File.Copy(_csvPath, CsvBakPath, overwrite: true); + } + + try + { + await File.WriteAllTextAsync(_csvPath, sb.ToString(), cancellationToken).ConfigureAwait(false); + + File.Delete(CsvBakPath); + } + catch + { + await File.WriteAllTextAsync(CsvBakPath, sb.ToString(), cancellationToken).ConfigureAwait(false); + } + } + finally + { + _fileWriteSemaphore.Release(); + } + } + private void EnsureCsvHeaderLocked() { if (!File.Exists(_csvPath)) @@ -577,7 +636,8 @@ public sealed class FileCacheManager : IHostedService private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null) { - hash ??= Crypto.GetFileHash(fileInfo.FullName); + var algo = Crypto.DetectAlgo(Path.GetFileNameWithoutExtension(fileInfo.Name)); + hash ??= Crypto.ComputeFileHash(fileInfo.FullName, algo); var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length); entity = ReplacePathPrefixes(entity); AddHashedFile(entity); @@ -585,13 +645,13 @@ public sealed class FileCacheManager : IHostedService { if (!File.Exists(_csvPath)) { - File.WriteAllLines(_csvPath, new[] { BuildVersionHeader(), entity.CsvEntry }); + File.WriteAllLines(_csvPath, [BuildVersionHeader(), entity.CsvEntry]); _csvHeaderEnsured = true; } else { EnsureCsvHeaderLockedCached(); - File.AppendAllLines(_csvPath, new[] { entity.CsvEntry }); + File.AppendAllLines(_csvPath, [entity.CsvEntry]); } } var result = GetFileCacheByPath(fileInfo.FullName); @@ -602,11 +662,17 @@ public sealed class FileCacheManager : IHostedService private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache) { var resultingFileCache = ReplacePathPrefixes(fileCache); - //_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath); resultingFileCache = Validate(resultingFileCache); return resultingFileCache; } + private async Task GetValidatedFileCacheAsync(FileCacheEntity fileCache, CancellationToken token = default) + { + var resultingFileCache = ReplacePathPrefixes(fileCache); + resultingFileCache = await ValidateAsync(resultingFileCache, token).ConfigureAwait(false); + return resultingFileCache; + } + private FileCacheEntity ReplacePathPrefixes(FileCacheEntity fileCache) { if (fileCache.PrefixedFilePath.StartsWith(PenumbraPrefix, StringComparison.OrdinalIgnoreCase)) @@ -629,6 +695,7 @@ public sealed class FileCacheManager : IHostedService RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); return null; } + var file = new FileInfo(fileCache.ResolvedFilepath); if (!file.Exists) { @@ -636,7 +703,8 @@ public sealed class FileCacheManager : IHostedService return null; } - if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal)) + var lastWriteTicks = file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + if (!string.Equals(lastWriteTicks, fileCache.LastModifiedDateTicks, StringComparison.Ordinal)) { UpdateHashedFile(fileCache); } @@ -644,7 +712,34 @@ public sealed class FileCacheManager : IHostedService return fileCache; } - public Task StartAsync(CancellationToken cancellationToken) + private async Task ValidateAsync(FileCacheEntity fileCache, CancellationToken token) + { + if (string.IsNullOrWhiteSpace(fileCache.ResolvedFilepath)) + { + _logger.LogWarning("FileCacheEntity has empty ResolvedFilepath for hash {hash}, prefixed path {prefixed}", fileCache.Hash, fileCache.PrefixedFilePath); + RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + return null; + } + + return await Task.Run(() => + { + var file = new FileInfo(fileCache.ResolvedFilepath); + if (!file.Exists) + { + RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + return null; + } + + if (!string.Equals(file.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileCache.LastModifiedDateTicks, StringComparison.Ordinal)) + { + UpdateHashedFile(fileCache); + } + + return fileCache; + }, token).ConfigureAwait(false); + } + + public async Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting FileCacheManager"); @@ -695,14 +790,14 @@ public sealed class FileCacheManager : IHostedService try { _logger.LogInformation("Attempting to read {csvPath}", _csvPath); - entries = File.ReadAllLines(_csvPath); + entries = await File.ReadAllLinesAsync(_csvPath, cancellationToken).ConfigureAwait(false); success = true; } catch (Exception ex) { attempts++; _logger.LogWarning(ex, "Could not open {file}, trying again", _csvPath); - Task.Delay(100, cancellationToken); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } } @@ -729,7 +824,7 @@ public sealed class FileCacheManager : IHostedService BackupUnsupportedCache("invalid-version"); parseEntries = false; rewriteRequired = true; - entries = Array.Empty(); + entries = []; } else if (parsedVersion != FileCacheVersion) { @@ -737,7 +832,7 @@ public sealed class FileCacheManager : IHostedService BackupUnsupportedCache($"v{parsedVersion}"); parseEntries = false; rewriteRequired = true; - entries = Array.Empty(); + entries = []; } else { @@ -817,20 +912,18 @@ public sealed class FileCacheManager : IHostedService if (rewriteRequired) { - WriteOutFullCsv(); + await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false); } } _logger.LogInformation("Started FileCacheManager"); - - _lightlessMediator.Publish(new FileCacheInitializedMessage()); - - return Task.CompletedTask; + _lightlessMediator.Publish(new FileCacheInitializedMessage()); + await Task.CompletedTask.ConfigureAwait(false); } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { - WriteOutFullCsv(); - return Task.CompletedTask; + await WriteOutFullCsvAsync(cancellationToken).ConfigureAwait(false); + await Task.CompletedTask.ConfigureAwait(false); } } \ No newline at end of file diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessSync/FileCache/FileCompactor.cs index 53377b6..771f558 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessSync/FileCache/FileCompactor.cs @@ -12,12 +12,11 @@ using static LightlessSync.Utils.FileSystemHelper; namespace LightlessSync.FileCache; -public sealed class FileCompactor : IDisposable +public sealed partial class FileCompactor : IDisposable { public const uint FSCTL_DELETE_EXTERNAL_BACKING = 0x90314U; public const ulong WOF_PROVIDER_FILE = 2UL; public const int _maxRetries = 3; - private readonly bool _isWindows; private readonly ConcurrentDictionary _pendingCompactions; private readonly ILogger _logger; @@ -31,23 +30,26 @@ public sealed class FileCompactor : IDisposable private readonly SemaphoreSlim _globalGate; //Limit btrfs gate on half of threads given to compactor. - private static readonly SemaphoreSlim _btrfsGate = new(4, 4); + private readonly SemaphoreSlim _btrfsGate; private readonly BatchFilefragService _fragBatch; - private readonly WOF_FILE_COMPRESSION_INFO_V1 _efInfo = new() + private readonly bool _isWindows; + private readonly int _workerCount; + + private readonly WofFileCompressionInfoV1 _efInfo = new() { Algorithm = (int)CompressionAlgorithm.XPRESS8K, Flags = 0 }; [StructLayout(LayoutKind.Sequential, Pack = 1)] - private struct WOF_FILE_COMPRESSION_INFO_V1 + private struct WofFileCompressionInfoV1 { public int Algorithm; public ulong Flags; } - private enum CompressionAlgorithm + private enum CompressionAlgorithm { NO_COMPRESSION = -2, LZNT1 = -1, @@ -71,29 +73,36 @@ public sealed class FileCompactor : IDisposable SingleWriter = false }); + //Amount of threads given for the compactor int workers = Math.Clamp(Math.Min(Environment.ProcessorCount / 2, 4), 1, 8); + //Setup gates for the threads and setup worker count _globalGate = new SemaphoreSlim(workers, workers); - int workerCount = Math.Max(workers * 2, workers); + _btrfsGate = new SemaphoreSlim(workers / 2, workers / 2); + _workerCount = Math.Max(workers * 2, workers); - for (int i = 0; i < workerCount; i++) + //Setup workers on the queue + for (int i = 0; i < _workerCount; i++) { + int workerId = i; + _workers.Add(Task.Factory.StartNew( - () => ProcessQueueWorkerAsync(_compactionCts.Token), + () => ProcessQueueWorkerAsync(workerId, _compactionCts.Token), _compactionCts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap()); } + //Uses an batching service for the filefrag command on Linux _fragBatch = new BatchFilefragService( useShell: _dalamudUtilService.IsWine, log: _logger, batchSize: 64, - flushMs: 25, + flushMs: 25, runDirect: RunProcessDirect, runShell: RunProcessShell ); - _logger.LogInformation("FileCompactor started with {workers} workers", workerCount); + _logger.LogInformation("FileCompactor started with {workers} workers", _workerCount); } public bool MassCompactRunning { get; private set; } @@ -103,37 +112,91 @@ public sealed class FileCompactor : IDisposable /// Compact the storage of the Cache Folder /// /// Used to check if files needs to be compressed - public void CompactStorage(bool compress) + public void CompactStorage(bool compress, int? maxDegree = null) { MassCompactRunning = true; + try { - var allFiles = Directory.EnumerateFiles(_lightlessConfigService.Current.CacheFolder).ToList(); - int total = allFiles.Count; - int current = 0; - - foreach (var file in allFiles) + var folder = _lightlessConfigService.Current.CacheFolder; + if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) { - current++; - Progress = $"{current}/{total}"; + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning("Filecompacator couldnt find your Cache folder: {folder}", folder); + Progress = "0/0"; + return; + } + + var files = Directory.EnumerateFiles(folder).ToArray(); + var total = files.Length; + Progress = $"0/{total}"; + if (total == 0) return; + + var degree = maxDegree ?? Math.Clamp(Environment.ProcessorCount / 2, 1, 8); + + var done = 0; + int workerCounter = -1; + var po = new ParallelOptions + { + MaxDegreeOfParallelism = degree, + CancellationToken = _compactionCts.Token + }; + + Parallel.ForEach(files, po, localInit: () => Interlocked.Increment(ref workerCounter), body: (file, state, workerId) => + { + _globalGate.WaitAsync(po.CancellationToken).GetAwaiter().GetResult(); + + if (!_pendingCompactions.TryAdd(file, 0)) + return -1; try { - // Compress or decompress files - if (compress) - CompactFile(file); - else - DecompressFile(file); + try + { + if (compress) + { + if (_lightlessConfigService.Current.UseCompactor) + CompactFile(file, workerId); + } + else + { + DecompressFile(file, workerId); + } + } + catch (IOException ioEx) + { + _logger.LogDebug(ioEx, "[W{worker}] File being read/written, skipping file: {file}", workerId, file); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[W{worker}] Error processing file: {file}", workerId, file); + } + finally + { + var n = Interlocked.Increment(ref done); + Progress = $"{n}/{total}"; + } } - catch (IOException ioEx) + finally { - _logger.LogDebug(ioEx, "File {file} locked or busy, skipping", file); + _pendingCompactions.TryRemove(file, out _); + _globalGate.Release(); } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error compacting/decompressing file {file}", file); - } - } + + return workerId; + }, + localFinally: _ => + { + //Ignore local finally for now + }); + } + catch (OperationCanceledException ex) + { + _logger.LogDebug(ex, "Mass compaction call got cancelled, shutting off compactor."); } finally { @@ -142,6 +205,7 @@ public sealed class FileCompactor : IDisposable } } + /// /// Write all bytes into a directory async /// @@ -207,16 +271,13 @@ public sealed class FileCompactor : IDisposable ? RunProcessShell($"stat -c='%b' {QuoteSingle(linuxPath)}", workingDir: null, 10000) : RunProcessDirect("stat", ["-c='%b'", linuxPath], workingDir: null, 10000); - if (ok && long.TryParse(output.Trim(), out long blocks)) - return (false, blocks * 512L); // st_blocks are always 512B units - - _logger.LogDebug("Btrfs size probe failed for {linux} (stat {code}, err {err}). Falling back to Length.", linuxPath, code, err); - return (false, fileInfo.Length); + return (flowControl: false, value: fileInfo.Length); } catch (Exception ex) { - _logger.LogDebug(ex, "Failed Btrfs size probe for {file}, using Length", fileInfo.FullName); - return (false, fileInfo.Length); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug(ex, "Failed Btrfs size probe for {file}, using Length", fileInfo.FullName); + return (flowControl: true, value: fileInfo.Length); } } @@ -257,19 +318,21 @@ public sealed class FileCompactor : IDisposable /// /// Compressing the given path with BTRFS or NTFS file system. /// - /// Path of the decompressed/normal file - private void CompactFile(string filePath) + /// Path of the decompressed/normal file + /// Worker/Process Id + private void CompactFile(string filePath, int workerId) { var fi = new FileInfo(filePath); if (!fi.Exists) { - _logger.LogTrace("Skip compaction: missing {file}", filePath); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("[W{worker}] Skip compaction: missing {file}", workerId, filePath); return; } var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); var oldSize = fi.Length; - int blockSize = GetBlockSizeForPath(fi.FullName, _logger, _dalamudUtilService.IsWine); + int blockSize = (int)(GetFileSizeOnDisk(fi) / 512); // We skipping small files (128KiB) as they slow down the system a lot for BTRFS. as BTRFS has a different blocksize it requires an different calculation. long minSizeBytes = fsType == FilesystemType.Btrfs @@ -278,7 +341,8 @@ public sealed class FileCompactor : IDisposable if (oldSize < minSizeBytes) { - _logger.LogTrace("Skip compaction: {file} ({size} B) < threshold ({th} B)", filePath, oldSize, minSizeBytes); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("[W{worker}] Skip compaction: {file} ({size} B) < threshold ({th} B)", workerId, filePath, oldSize, minSizeBytes); return; } @@ -286,20 +350,20 @@ public sealed class FileCompactor : IDisposable { if (!IsWOFCompactedFile(filePath)) { - _logger.LogDebug("NTFS compaction XPRESS8K: {file}", filePath); if (WOFCompressFile(filePath)) { var newSize = GetFileSizeOnDisk(fi); - _logger.LogDebug("NTFS compressed {file} {old} -> {new}", filePath, oldSize, newSize); + _logger.LogDebug("[W{worker}] NTFS compressed XPRESS8K {file} {old} -> {new}", workerId, filePath, oldSize, newSize); } else { - _logger.LogWarning("NTFS compression failed or unavailable for {file}", filePath); + _logger.LogWarning("[W{worker}] NTFS compression failed or unavailable for {file}", workerId, filePath); } } else { - _logger.LogTrace("Already NTFS-compressed: {file}", filePath); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("[W{worker}] Already NTFS-compressed with XPRESS8K: {file}", workerId, filePath); } return; } @@ -308,41 +372,43 @@ public sealed class FileCompactor : IDisposable { if (!IsBtrfsCompressedFile(filePath)) { - _logger.LogDebug("Btrfs compression zstd: {file}", filePath); if (BtrfsCompressFile(filePath)) { var newSize = GetFileSizeOnDisk(fi); - _logger.LogDebug("Btrfs compressed {file} {old} -> {new}", filePath, oldSize, newSize); + _logger.LogDebug("[W{worker}] Btrfs compressed clzo {file} {old} -> {new}", workerId, filePath, oldSize, newSize); } else { - _logger.LogWarning("Btrfs compression failed or unavailable for {file}", filePath); + _logger.LogWarning("[W{worker}] Btrfs compression failed or unavailable for {file}", workerId, filePath); } } else { - _logger.LogTrace("Already Btrfs-compressed: {file}", filePath); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("[W{worker}] Already Btrfs-compressed with clzo: {file}", workerId, filePath); } return; } - _logger.LogTrace("Skip compact: unsupported FS for {file}", filePath); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("[W{worker}] Skip compact: unsupported FS for {file}", workerId, filePath); } /// /// Decompressing the given path with BTRFS file system or NTFS file system. /// - /// Path of the compressed file - private void DecompressFile(string path) + /// Path of the decompressed/normal file + /// Worker/Process Id + private void DecompressFile(string filePath, int workerId) { - _logger.LogDebug("Decompress request: {file}", path); - var fsType = GetFilesystemType(path, _dalamudUtilService.IsWine); + _logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath); + var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) { try { - bool flowControl = DecompressWOFFile(path); + bool flowControl = DecompressWOFFile(filePath, workerId); if (!flowControl) { return; @@ -350,7 +416,7 @@ public sealed class FileCompactor : IDisposable } catch (Exception ex) { - _logger.LogWarning(ex, "NTFS decompress error {file}", path); + _logger.LogWarning(ex, "[W{worker}] NTFS decompress error {file}", workerId, filePath); } } @@ -358,7 +424,7 @@ public sealed class FileCompactor : IDisposable { try { - bool flowControl = DecompressBtrfsFile(path); + bool flowControl = DecompressBtrfsFile(filePath); if (!flowControl) { return; @@ -366,7 +432,7 @@ public sealed class FileCompactor : IDisposable } catch (Exception ex) { - _logger.LogWarning(ex, "Btrfs decompress error {file}", path); + _logger.LogWarning(ex, "[W{worker}] Btrfs decompress error {file}", workerId, filePath); } } } @@ -386,51 +452,48 @@ public sealed class FileCompactor : IDisposable string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; var opts = GetMountOptionsForPath(linuxPath); - bool hasCompress = opts.Contains("compress", StringComparison.OrdinalIgnoreCase); - bool hasCompressForce = opts.Contains("compress-force", StringComparison.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(opts)) + _logger.LogTrace("Mount opts for {file}: {opts}", linuxPath, opts); - if (hasCompressForce) + var probe = RunProcessShell("command -v btrfs || which btrfs", timeoutMs: 5000); + var _btrfsAvailable = probe.ok && !string.IsNullOrWhiteSpace(probe.stdout); + if (!_btrfsAvailable) + _logger.LogWarning("btrfs cli not found in path. Compression will be skipped."); + + var prop = isWine + ? RunProcessShell($"btrfs property set -- {QuoteSingle(linuxPath)} compression none", timeoutMs: 15000) + : RunProcessDirect("btrfs", ["property", "set", "--", linuxPath, "compression", "none"], "/", 15000); + + if (prop.ok) _logger.LogTrace("Set per-file 'compression none' on {file}", linuxPath); + else _logger.LogTrace("btrfs property set failed for {file} (exit {code}): {err}", linuxPath, prop.exitCode, prop.stderr); + + var defrag = isWine + ? RunProcessShell($"btrfs filesystem defragment -f -- {QuoteSingle(linuxPath)}", timeoutMs: 60000) + : RunProcessDirect("btrfs", ["filesystem", "defragment", "-f", "--", linuxPath], "/", 60000); + + if (!defrag.ok) { - _logger.LogWarning("Cannot safely decompress {file}: mount options contains compress-force ({opts}).", linuxPath, opts); + _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit {code}): {err}", + linuxPath, defrag.exitCode, defrag.stderr); return false; } - if (hasCompress) - { - var setCmd = $"btrfs property set -- {QuoteDouble(linuxPath)} compression none"; - var (okSet, _, errSet, codeSet) = isWine - ? RunProcessShell(setCmd) - : RunProcessDirect("btrfs", ["property", "set", "--", linuxPath, "compression", "none"]); - - if (!okSet) - { - _logger.LogWarning("Failed to set 'compression none' on {file}, please check drive options (exit code is: {code}): {err}", linuxPath, codeSet, errSet); - return false; - } - _logger.LogTrace("Set per-file 'compression none' on {file}", linuxPath); - } - - if (!IsBtrfsCompressedFile(linuxPath)) - { - _logger.LogTrace("{file} is not compressed, skipping decompression completely", linuxPath); - return true; - } - - var (ok, stdout, stderr, code) = isWine - ? RunProcessShell($"btrfs filesystem defragment -- {QuoteDouble(linuxPath)}") - : RunProcessDirect("btrfs", ["filesystem", "defragment", "--", linuxPath]); - - if (!ok) - { - _logger.LogWarning("btrfs defragment (decompress) failed for {file} (exit code is: {code}): {stderr}", - linuxPath, code, stderr); - return false; - } - - if (!string.IsNullOrWhiteSpace(stdout)) - _logger.LogTrace("btrfs defragment output for {file}: {out}", linuxPath, stdout.Trim()); + if (!string.IsNullOrWhiteSpace(defrag.stdout)) + _logger.LogTrace("btrfs defragment output for {file}: {out}", linuxPath, defrag.stdout.Trim()); _logger.LogInformation("Decompressed (rewritten uncompressed) Btrfs file: {file}", linuxPath); + + try + { + if (_fragBatch != null) + { + var compressed = _fragBatch.IsCompressedAsync(linuxPath, _compactionCts.Token).GetAwaiter().GetResult(); + if (compressed) + _logger.LogTrace("Post-check: {file} still shows 'compressed' flag (may be stale).", linuxPath); + } + } + catch { /* ignore verification noisy */ } + return true; } catch (Exception ex) @@ -446,18 +509,18 @@ public sealed class FileCompactor : IDisposable /// /// Path of the compressed file /// Decompressing state - private bool DecompressWOFFile(string path) + private bool DecompressWOFFile(string path, int workerID) { //Check if its already been compressed if (TryIsWofExternal(path, out bool isExternal, out int algo)) { if (!isExternal) { - _logger.LogTrace("Already decompressed file: {file}", path); + _logger.LogTrace("[W{worker}] Already decompressed file: {file}", workerID, path); return true; } var compressString = ((CompressionAlgorithm)algo).ToString(); - _logger.LogTrace("WOF compression (algo={algo}) detected for {file}", compressString, path); + _logger.LogTrace("[W{worker}] WOF compression (algo={algo}) detected for {file}", workerID, compressString, path); } //This will attempt to start WOF thread. @@ -471,15 +534,15 @@ public sealed class FileCompactor : IDisposable // 342 error code means its been decompressed after the control, we handle it as it succesfully been decompressed. if (err == 342) { - _logger.LogTrace("Successfully decompressed NTFS file {file}", path); + _logger.LogTrace("[W{worker}] Successfully decompressed NTFS file {file}", workerID, path); return true; } - _logger.LogWarning("DeviceIoControl failed for {file} with Win32 error {err}", path, err); + _logger.LogWarning("[W{worker}] DeviceIoControl failed for {file} with Win32 error {err}", workerID, path, err); return false; } - _logger.LogTrace("Successfully decompressed NTFS file {file}", path); + _logger.LogTrace("[W{worker}] Successfully decompressed NTFS file {file}", workerID, path); return true; }); } @@ -492,6 +555,7 @@ public sealed class FileCompactor : IDisposable /// Converted path to be used in Linux private string ToLinuxPathIfWine(string path, bool isWine, bool preferShell = true) { + //Return if not wine if (!isWine || !IsProbablyWine()) return path; @@ -553,7 +617,7 @@ public sealed class FileCompactor : IDisposable /// Compessing state private bool WOFCompressFile(string path) { - int size = Marshal.SizeOf(); + int size = Marshal.SizeOf(); IntPtr efInfoPtr = Marshal.AllocHGlobal(size); try @@ -606,7 +670,7 @@ public sealed class FileCompactor : IDisposable { try { - uint buf = (uint)Marshal.SizeOf(); + uint buf = (uint)Marshal.SizeOf(); int result = WofIsExternalFile(filePath, out int isExternal, out _, out var info, ref buf); if (result != 0 || isExternal == 0) return false; @@ -635,7 +699,7 @@ public sealed class FileCompactor : IDisposable algorithm = 0; try { - uint buf = (uint)Marshal.SizeOf(); + uint buf = (uint)Marshal.SizeOf(); int hr = WofIsExternalFile(path, out int ext, out _, out var info, ref buf); if (hr == 0 && ext != 0) { @@ -644,13 +708,13 @@ public sealed class FileCompactor : IDisposable } return true; } - catch (DllNotFoundException) + catch (DllNotFoundException) { - return false; + return false; } - catch (EntryPointNotFoundException) - { - return false; + catch (EntryPointNotFoundException) + { + return false; } } @@ -665,8 +729,7 @@ public sealed class FileCompactor : IDisposable { try { - bool windowsProc = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - string linuxPath = windowsProc ? ResolveLinuxPathForWine(path) : path; + string linuxPath = _isWindows ? ResolveLinuxPathForWine(path) : path; var task = _fragBatch.IsCompressedAsync(linuxPath, _compactionCts.Token); @@ -712,6 +775,11 @@ public sealed class FileCompactor : IDisposable return false; } + var probe = RunProcessShell("command -v btrfs || which btrfs", timeoutMs: 5000); + var _btrfsAvailable = probe.ok && !string.IsNullOrWhiteSpace(probe.stdout); + if (!_btrfsAvailable) + _logger.LogWarning("btrfs cli not found in path. Compression will be skipped."); + (bool ok, string stdout, string stderr, int code) = _isWindows ? RunProcessShell($"btrfs filesystem defragment -clzo -- {QuoteSingle(linuxPath)}") @@ -796,9 +864,10 @@ public sealed class FileCompactor : IDisposable RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + WorkingDirectory = workingDir ?? "/", }; - if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; + foreach (var a in args) psi.ArgumentList.Add(a); EnsureUnixPathEnv(psi); @@ -812,8 +881,18 @@ public sealed class FileCompactor : IDisposable } int code; - try { code = proc.ExitCode; } catch { code = -1; } - return (code == 0, so2, se2, code); + try { code = proc.ExitCode; } + catch { code = -1; } + + bool ok = code == 0; + + if (!ok && code == -1 && + string.IsNullOrWhiteSpace(se2) && !string.IsNullOrWhiteSpace(so2)) + { + ok = true; + } + + return (ok, so2, se2, code); } /// @@ -824,15 +903,14 @@ public sealed class FileCompactor : IDisposable /// State of the process, output of the process and error with exit code private (bool ok, string stdout, string stderr, int exitCode) RunProcessShell(string command, string? workingDir = null, int timeoutMs = 60000) { - var psi = new ProcessStartInfo("/bin/bash") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = true + CreateNoWindow = true, + WorkingDirectory = workingDir ?? "/", }; - if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir; // Use a Login shell so PATH includes /usr/sbin etc. AKA -lc for login shell psi.ArgumentList.Add("-lc"); @@ -849,65 +927,72 @@ public sealed class FileCompactor : IDisposable } int code; - try { code = proc.ExitCode; } catch { code = -1; } - return (code == 0, so2, se2, code); + try { code = proc.ExitCode; } + catch { code = -1; } + + bool ok = code == 0; + + if (!ok && code == -1 && string.IsNullOrWhiteSpace(se2) && !string.IsNullOrWhiteSpace(so2)) + { + ok = true; + } + + return (ok, so2, se2, code); } /// /// Checking the process result for shell or direct processes /// /// Process - /// How long when timeout is gotten + /// How long when timeout goes over threshold /// Cancellation Token /// Multiple variables - private (bool success, string testy, string testi) CheckProcessResult(Process proc, int timeoutMs, CancellationToken token) + private (bool success, string output, string errorCode) CheckProcessResult(Process proc, int timeoutMs, CancellationToken token) { var outTask = proc.StandardOutput.ReadToEndAsync(token); var errTask = proc.StandardError.ReadToEndAsync(token); var bothTasks = Task.WhenAll(outTask, errTask); - //On wine, we dont wanna use waitforexit as it will be always broken and giving an error. - if (_dalamudUtilService.IsWine) - { - var finished = Task.WhenAny(bothTasks, Task.Delay(timeoutMs, token)).GetAwaiter().GetResult(); - if (finished != bothTasks) - { - try - { - proc.Kill(entireProcessTree: true); - Task.WaitAll([outTask, errTask], 1000, token); - } - catch - { - // ignore this - } - var so = outTask.IsCompleted ? outTask.Result : ""; - var se = errTask.IsCompleted ? errTask.Result : "timeout"; - return (false, so, se); - } + var finished = Task.WhenAny(bothTasks, Task.Delay(timeoutMs, token)).GetAwaiter().GetResult(); - var stderr = errTask.Result; - var ok = string.IsNullOrWhiteSpace(stderr); - return (ok, outTask.Result, stderr); + if (token.IsCancellationRequested) + return KillProcess(proc, outTask, errTask, token); + + if (finished != bothTasks) + return KillProcess(proc, outTask, errTask, token); + + bool isWine = _dalamudUtilService?.IsWine ?? false; + if (!isWine) + { + try { proc.WaitForExit(); } catch { /* ignore quirks */ } + } + else + { + var sw = Stopwatch.StartNew(); + while (!proc.HasExited && sw.ElapsedMilliseconds < 75) + Thread.Sleep(5); } - // On linux, we can use it as we please - if (!proc.WaitForExit(timeoutMs)) - { - try - { - proc.Kill(entireProcessTree: true); - Task.WaitAll([outTask, errTask], 1000, token); - } - catch - { - // ignore this - } - return (false, outTask.IsCompleted ? outTask.Result : "", "timeout"); - } + var stdout = outTask.Status == TaskStatus.RanToCompletion ? outTask.Result : ""; + var stderr = errTask.Status == TaskStatus.RanToCompletion ? errTask.Result : ""; - Task.WaitAll(outTask, errTask); - return (true, outTask.Result, errTask.Result); + int code = -1; + try { if (proc.HasExited) code = proc.ExitCode; } catch { /* Wine may still throw */ } + + bool ok = code == 0 || (isWine && string.IsNullOrWhiteSpace(stderr)); + + return (ok, stdout, stderr); + + static (bool success, string output, string errorCode) KillProcess( + Process proc, Task outTask, Task errTask, CancellationToken token) + { + try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ } + try { Task.WaitAll([outTask, errTask], 1000, token); } catch { /* ignore */ } + + var so = outTask.IsCompleted ? outTask.Result : ""; + var se = errTask.IsCompleted ? errTask.Result : "canceled/timeout"; + return (false, so, se); + } } /// @@ -967,10 +1052,10 @@ public sealed class FileCompactor : IDisposable } /// - /// Process the queue with, meant for a worker/thread + /// Process the queue, meant for a worker/thread /// /// Cancellation token for the worker whenever it needs to be stopped - private async Task ProcessQueueWorkerAsync(CancellationToken token) + private async Task ProcessQueueWorkerAsync(int workerId, CancellationToken token) { try { @@ -986,7 +1071,7 @@ public sealed class FileCompactor : IDisposable try { if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath)) - CompactFile(filePath); + CompactFile(filePath, workerId); } finally { @@ -1005,8 +1090,8 @@ public sealed class FileCompactor : IDisposable } } } - catch (OperationCanceledException) - { + catch (OperationCanceledException) + { // Shutting down worker, this exception is expected } } @@ -1018,7 +1103,7 @@ public sealed class FileCompactor : IDisposable /// Linux path to be used in Linux private string ResolveLinuxPathForWine(string windowsPath) { - var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", null, 5000); + var (ok, outp, _, _) = RunProcessShell($"winepath -u {QuoteSingle(windowsPath)}", workingDir: null, 5000); if (ok && !string.IsNullOrWhiteSpace(outp)) return outp.Trim(); return ToLinuxPathIfWine(windowsPath, isWine: true); } @@ -1071,7 +1156,11 @@ public sealed class FileCompactor : IDisposable } return true; } - catch { return false; } + catch (Exception ex) + { + _logger.LogTrace(ex, "Probe open failed for {file} (linux={linux})", winePath, linuxPath); + return false; + } } /// @@ -1096,17 +1185,18 @@ public sealed class FileCompactor : IDisposable } - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial uint GetCompressedFileSizeW([MarshalAs(UnmanagedType.LPWStr)] string lpFileName, out uint lpFileSizeHigh); - [DllImport("kernel32.dll")] - private static extern uint GetCompressedFileSizeW([In, MarshalAs(UnmanagedType.LPWStr)] string lpFileName, [Out, MarshalAs(UnmanagedType.U4)] out uint lpFileSizeHigh); + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool DeviceIoControl(SafeFileHandle hDevice, uint dwIoControlCode, IntPtr lpInBuffer, uint nInBufferSize, IntPtr lpOutBuffer, uint nOutBufferSize, out uint lpBytesReturned, IntPtr lpOverlapped); - [DllImport("WofUtil.dll")] - private static extern int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WOF_FILE_COMPRESSION_INFO_V1 Info, ref uint BufferLength); + [LibraryImport("WofUtil.dll")] + private static partial int WofIsExternalFile([MarshalAs(UnmanagedType.LPWStr)] string Filepath, out int IsExternalFile, out uint Provider, out WofFileCompressionInfoV1 Info, ref uint BufferLength); - [DllImport("WofUtil.dll", SetLastError = true)] - private static extern int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length); + [LibraryImport("WofUtil.dll")] + private static partial int WofSetFileDataLocation(SafeFileHandle FileHandle, ulong Provider, IntPtr ExternalFileInfo, ulong Length); private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; @@ -1114,7 +1204,11 @@ public sealed class FileCompactor : IDisposable public void Dispose() { + //Cleanup of gates and frag service _fragBatch?.Dispose(); + _btrfsGate?.Dispose(); + _globalGate?.Dispose(); + _compactionQueue.Writer.TryComplete(); _compactionCts.Cancel(); @@ -1122,8 +1216,8 @@ public sealed class FileCompactor : IDisposable { Task.WaitAll([.. _workers.Where(t => t != null)], TimeSpan.FromSeconds(5)); } - catch - { + catch + { // Ignore this catch on the dispose } finally diff --git a/LightlessSync/FileCache/FileState.cs b/LightlessSync/FileCache/FileState.cs index dfad917..0a1088b 100644 --- a/LightlessSync/FileCache/FileState.cs +++ b/LightlessSync/FileCache/FileState.cs @@ -5,4 +5,5 @@ public enum FileState Valid, RequireUpdate, RequireDeletion, + RequireRehash } \ No newline at end of file diff --git a/LightlessSync/Interop/DalamudLogger.cs b/LightlessSync/Interop/DalamudLogger.cs index 3a833b9..24fcac2 100644 --- a/LightlessSync/Interop/DalamudLogger.cs +++ b/LightlessSync/Interop/DalamudLogger.cs @@ -20,7 +20,10 @@ internal sealed class DalamudLogger : ILogger _hasModifiedGameFiles = hasModifiedGameFiles; } - public IDisposable BeginScope(TState state) => default!; + IDisposable? ILogger.BeginScope(TState state) + { + return default!; + } public bool IsEnabled(LogLevel logLevel) { diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 13f5104..975e935 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -10,7 +10,7 @@ - net9.0-windows + net9.0-windows7.0 x64 enable latest @@ -27,6 +27,7 @@ + diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 5136a6e..6e68f77 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -288,7 +288,7 @@ public sealed class Plugin : IDalamudPlugin clientState, sp.GetRequiredService())); collection.AddSingleton(); - collection.AddSingleton(s => new BroadcastScannerService( s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddSingleton(s => new BroadcastScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); // add scoped services @@ -342,7 +342,7 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService())); collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), clientState, gameGui, objectTable, gameInteropProvider, s.GetRequiredService(),s.GetRequiredService())); - collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, s.GetRequiredService(), + collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, s.GetRequiredService(), s.GetRequiredService(), clientState, s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); diff --git a/LightlessSync/Services/BroadcastScanningService.cs b/LightlessSync/Services/BroadcastScannerService.cs similarity index 86% rename from LightlessSync/Services/BroadcastScanningService.cs rename to LightlessSync/Services/BroadcastScannerService.cs index 45f0fa1..96576d1 100644 --- a/LightlessSync/Services/BroadcastScanningService.cs +++ b/LightlessSync/Services/BroadcastScannerService.cs @@ -1,6 +1,5 @@ using Dalamud.Plugin.Services; using LightlessSync.API.Dto.User; -using LightlessSync.LightlessConfiguration; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; @@ -8,7 +7,7 @@ using System.Collections.Concurrent; namespace LightlessSync.Services; -public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDisposable +public class BroadcastScannerService : DisposableMediatorSubscriberBase { private readonly ILogger _logger; private readonly ActorObjectService _actorTracker; @@ -17,22 +16,21 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos private readonly BroadcastService _broadcastService; private readonly NameplateHandler _nameplateHandler; - private readonly ConcurrentDictionary _broadcastCache = new(); + private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); private readonly Queue _lookupQueue = new(); - private readonly HashSet _lookupQueuedCids = new(); - private readonly HashSet _syncshellCids = new(); + private readonly HashSet _lookupQueuedCids = []; + private readonly HashSet _syncshellCids = []; - private static readonly TimeSpan MaxAllowedTtl = TimeSpan.FromMinutes(4); - private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1); + private static readonly TimeSpan _maxAllowedTtl = TimeSpan.FromMinutes(4); + private static readonly TimeSpan _retryDelay = TimeSpan.FromMinutes(1); private readonly CancellationTokenSource _cleanupCts = new(); - private Task? _cleanupTask; + private readonly Task? _cleanupTask; private readonly int _checkEveryFrames = 20; private int _frameCounter = 0; - private int _lookupsThisFrame = 0; - private const int MaxLookupsPerFrame = 30; - private const int MaxQueueSize = 100; + private const int _maxLookupsPerFrame = 30; + private const int _maxQueueSize = 100; private volatile bool _batchRunning = false; @@ -59,6 +57,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop); _nameplateHandler.Init(); + _actorTracker = actorTracker; } private void OnFrameworkUpdate(IFramework framework) => Update(); @@ -66,7 +65,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos public void Update() { _frameCounter++; - _lookupsThisFrame = 0; + var lookupsThisFrame = 0; if (!_broadcastService.IsBroadcasting) return; @@ -81,19 +80,19 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); var isStale = !_broadcastCache.TryGetValue(cid, out var entry) || entry.ExpiryTime <= now; - if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < MaxQueueSize) + if (isStale && _lookupQueuedCids.Add(cid) && _lookupQueue.Count < _maxQueueSize) _lookupQueue.Enqueue(cid); } if (_frameCounter % _checkEveryFrames == 0 && _lookupQueue.Count > 0) { var cidsToLookup = new List(); - while (_lookupQueue.Count > 0 && _lookupsThisFrame < MaxLookupsPerFrame) + while (_lookupQueue.Count > 0 && lookupsThisFrame < _maxLookupsPerFrame) { var cid = _lookupQueue.Dequeue(); _lookupQueuedCids.Remove(cid); cidsToLookup.Add(cid); - _lookupsThisFrame++; + lookupsThisFrame++; } if (cidsToLookup.Count > 0 && !_batchRunning) @@ -115,8 +114,8 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos continue; var ttl = info.IsBroadcasting && info.TTL.HasValue - ? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, MaxAllowedTtl.Ticks)) - : RetryDelay; + ? TimeSpan.FromTicks(Math.Min(info.TTL.Value.Ticks, _maxAllowedTtl.Ticks)) + : _retryDelay; var expiry = now + ttl; @@ -153,7 +152,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos var newSet = _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Select(e => e.Key) - .ToHashSet(); + .ToHashSet(StringComparer.Ordinal); if (!_syncshellCids.SetEquals(newSet)) { @@ -169,7 +168,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos { var now = DateTime.UtcNow; - return _broadcastCache + return [.. _broadcastCache .Where(e => e.Value.IsBroadcasting && e.Value.ExpiryTime > now && !string.IsNullOrEmpty(e.Value.GID)) .Select(e => new BroadcastStatusInfoDto { @@ -177,8 +176,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos IsBroadcasting = true, TTL = e.Value.ExpiryTime - now, GID = e.Value.GID - }) - .ToList(); + })]; } private async Task ExpiredBroadcastCleanupLoop() @@ -189,7 +187,7 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos { while (!token.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromSeconds(10), token); + await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(false); var now = DateTime.UtcNow; foreach (var (cid, entry) in _broadcastCache.ToArray()) @@ -199,7 +197,10 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos } } } - catch (OperationCanceledException) { } + catch (OperationCanceledException) + { + // No action needed when cancelled + } catch (Exception ex) { _logger.LogError(ex, "Broadcast cleanup loop crashed"); @@ -232,7 +233,14 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase, IDispos { base.Dispose(disposing); _framework.Update -= OnFrameworkUpdate; + if (_cleanupTask != null) + { + _cleanupTask?.Wait(100, _cleanupCts.Token); + } + _cleanupCts.Cancel(); + _cleanupCts.Dispose(); + _cleanupTask?.Wait(100); _cleanupCts.Dispose(); _nameplateHandler.Uninit(); diff --git a/LightlessSync/Services/BroadcastService.cs b/LightlessSync/Services/BroadcastService.cs index cca9af6..bc32c9c 100644 --- a/LightlessSync/Services/BroadcastService.cs +++ b/LightlessSync/Services/BroadcastService.cs @@ -11,7 +11,6 @@ using LightlessSync.WebAPI; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using System.Threading; namespace LightlessSync.Services; public class BroadcastService : IHostedService, IMediatorSubscriber @@ -58,7 +57,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber { if (!_apiController.IsConnected) { - _logger.LogDebug(context + " skipped, not connected"); + _logger.LogDebug("{context} skipped, not connected", context); return; } await action().ConfigureAwait(false); @@ -372,7 +371,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber public async Task> AreUsersBroadcastingAsync(List hashedCids) { - Dictionary result = new(); + Dictionary result = new(StringComparer.Ordinal); await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () => { @@ -397,8 +396,6 @@ public class BroadcastService : IHostedService, IMediatorSubscriber return result; } - - public async void ToggleBroadcast() { diff --git a/LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs b/LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs new file mode 100644 index 0000000..0eaf312 --- /dev/null +++ b/LightlessSync/Services/CharaData/CharacterAnalysisSummary.cs @@ -0,0 +1,19 @@ +using LightlessSync.API.Data.Enum; +using LightlessSync.Services.CharaData.Models; +using System.Collections.Immutable; +namespace LightlessSync.Services.CharaData; + +public sealed class CharacterAnalysisSummary +{ + public static CharacterAnalysisSummary Empty { get; } = + new(ImmutableDictionary.Empty); + + internal CharacterAnalysisSummary(IImmutableDictionary objects) + { + Objects = objects; + } + + public IImmutableDictionary Objects { get; } + + public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries); +} \ No newline at end of file diff --git a/LightlessSync/Services/CharaData/Models/CharacterAnalysisObjectSummary.cs b/LightlessSync/Services/CharaData/Models/CharacterAnalysisObjectSummary.cs new file mode 100644 index 0000000..aa42394 --- /dev/null +++ b/LightlessSync/Services/CharaData/Models/CharacterAnalysisObjectSummary.cs @@ -0,0 +1,8 @@ +using System.Runtime.InteropServices; +namespace LightlessSync.Services.CharaData.Models; + +[StructLayout(LayoutKind.Auto)] +public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes) +{ + public bool HasEntries => EntryCount > 0; +} diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 75c25d6..a56b6e3 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -1,16 +1,14 @@ using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.FileCache; +using LightlessSync.Services.CharaData; +using LightlessSync.Services.CharaData.Models; using LightlessSync.Services.Mediator; using LightlessSync.UI; using LightlessSync.Utils; using Lumina.Data.Files; using Microsoft.Extensions.Logging; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; namespace LightlessSync.Services; public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable @@ -51,31 +49,47 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _analysisCts = _analysisCts?.CancelRecreate() ?? new(); var cancelToken = _analysisCts.Token; var allFiles = LastAnalysis.SelectMany(v => v.Value.Select(d => d.Value)).ToList(); - if (allFiles.Exists(c => !c.IsComputed || recalculate)) + + var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList(); + + if (remaining.Count == 0) + return; + + TotalFiles = remaining.Count; + CurrentFile = 0; + + Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count); + + Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer))); + + try { - var remaining = allFiles.Where(c => !c.IsComputed || recalculate).ToList(); - TotalFiles = remaining.Count; - CurrentFile = 1; - Logger.LogDebug("=== Computing {amount} remaining files ===", remaining.Count); - Mediator.Publish(new HaltScanMessage(nameof(CharacterAnalyzer))); - try + foreach (var file in remaining) { - foreach (var file in remaining) - { - Logger.LogDebug("Computing file {file}", file.FilePaths[0]); - await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false); - CurrentFile++; - } - _fileCacheManager.WriteOutFullCsv(); - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to analyze files"); - } - finally - { - Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer))); + cancelToken.ThrowIfCancellationRequested(); + + var path = file.FilePaths.FirstOrDefault() ?? ""; + Logger.LogDebug("Computing file {file}", path); + + await file.ComputeSizes(_fileCacheManager, cancelToken).ConfigureAwait(false); + + CurrentFile++; } + + await _fileCacheManager.WriteOutFullCsvAsync(cancelToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Logger.LogInformation("File analysis cancelled"); + throw; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to analyze files"); + } + finally + { + Mediator.Publish(new ResumeScanMessage(nameof(CharacterAnalyzer))); } RecalculateSummary(); @@ -87,6 +101,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable public void Dispose() { _analysisCts.CancelDispose(); + _baseAnalysisCts.Dispose(); } public async Task UpdateFileEntriesAsync(IEnumerable filePaths, CancellationToken token) { @@ -120,7 +135,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable foreach (var fileEntry in obj.Value) { token.ThrowIfCancellationRequested(); - var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList(); + + var fileCacheEntries = (await _fileCacheManager.GetAllFileCachesByHashAsync(fileEntry.Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false)).ToList(); if (fileCacheEntries.Count == 0) continue; var filePath = fileCacheEntries[0].ResolvedFilepath; FileInfo fi = new(filePath); @@ -138,7 +154,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, [.. fileEntry.GamePaths], - fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct().ToList(), + [.. fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct(StringComparer.Ordinal)], entry.Size > 0 ? entry.Size.Value : 0, entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0, tris); @@ -226,7 +242,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { var compressedsize = await fileCacheManager.GetCompressedFileData(Hash, token).ConfigureAwait(false); var normalSize = new FileInfo(FilePaths[0]).Length; - var entries = fileCacheManager.GetAllFileCachesByHash(Hash, ignoreCacheEntries: true, validate: false); + var entries = await fileCacheManager.GetAllFileCachesByHashAsync(Hash, ignoreCacheEntries: true, validate: false, token).ConfigureAwait(false); foreach (var entry in entries) { entry.Size = normalSize; @@ -263,23 +279,3 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable }); } } - -public readonly record struct CharacterAnalysisObjectSummary(int EntryCount, long TotalTriangles, long TexOriginalBytes, long TexCompressedBytes) -{ - public bool HasEntries => EntryCount > 0; -} - -public sealed class CharacterAnalysisSummary -{ - public static CharacterAnalysisSummary Empty { get; } = - new(ImmutableDictionary.Empty); - - internal CharacterAnalysisSummary(IImmutableDictionary objects) - { - Objects = objects; - } - - public IImmutableDictionary Objects { get; } - - public bool HasData => Objects.Any(kvp => kvp.Value.HasEntries); -} \ No newline at end of file diff --git a/LightlessSync/Services/Compactor/BatchFileFragService.cs b/LightlessSync/Services/Compactor/BatchFileFragService.cs index b31919e..b99934b 100644 --- a/LightlessSync/Services/Compactor/BatchFileFragService.cs +++ b/LightlessSync/Services/Compactor/BatchFileFragService.cs @@ -92,13 +92,13 @@ namespace LightlessSync.Services.Compactor } if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break; - try - { - await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false); + try + { + await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false); } - catch - { - break; + catch + { + break; } } @@ -124,8 +124,8 @@ namespace LightlessSync.Services.Compactor } } } - catch (OperationCanceledException) - { + catch (OperationCanceledException) + { //Shutting down worker, exception called } } @@ -145,17 +145,13 @@ namespace LightlessSync.Services.Compactor if (_useShell) { - var inner = "filefrag -v " + string.Join(' ', list.Select(QuoteSingle)); + var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle)); res = _runShell(inner, timeoutMs: 15000, workingDir: "/"); } else { - var args = new List { "-v" }; - foreach (var path in list) - { - args.Add(' ' + path); - } - + var args = new List { "-v", "--" }; + args.AddRange(list); res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000); } @@ -200,7 +196,7 @@ namespace LightlessSync.Services.Compactor /// Regex of the File Size return on the Linux/Wine systems, giving back the amount /// /// Regex of the File Size - [GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant,matchTimeoutMilliseconds: 500)] + [GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)] private static partial Regex SizeRegex(); /// diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 8c45474..42cac86 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -106,7 +106,7 @@ internal class ContextMenuService : IHostedService return; IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); - if (targetData == null || targetData.Address == nint.Zero) + if (targetData == null || targetData.Address == nint.Zero || _clientState.LocalPlayer == null) return; //Check if user is directly paired or is own. @@ -161,7 +161,7 @@ internal class ContextMenuService : IHostedService PrefixChar = 'L', UseDefaultPrefix = false, PrefixColor = 708, - OnClicked = async _ => await HandleSelection(args).ConfigureAwait(false) + OnClicked = _ => HandleSelection(args).ConfigureAwait(false).GetAwaiter().GetResult() }); } @@ -190,7 +190,7 @@ internal class ContextMenuService : IHostedService return; } - var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); + var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetBlake3Hash(); var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); @@ -286,8 +286,6 @@ internal class ContextMenuService : IHostedService private static bool IsChineseJapaneseKoreanCharacter(char c) => c >= 0x4E00 && c <= 0x9FFF; - public bool IsWorldValid(uint worldId) => IsWorldValid(GetWorld(worldId)); - public static bool IsWorldValid(World world) { var name = world.Name.ToString(); diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 716523d..66045a1 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -531,15 +531,11 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber { logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler); curWaitTime += tick; - await Task.Delay(tick).ConfigureAwait(true); + await Task.Delay(tick, ct.Value).ConfigureAwait(true); } logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime); } - catch (NullReferenceException ex) - { - logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); - } catch (AccessViolationException ex) { logger.LogWarning(ex, "Error accessing {handler}, object does not exist anymore?", handler); @@ -707,76 +703,75 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _lastGlobalBlockReason = string.Empty; } - if (_clientState.IsGPosing && !IsInGpose) - { - _logger.LogDebug("Gpose start"); - IsInGpose = true; - Mediator.Publish(new GposeStartMessage()); - } - else if (!_clientState.IsGPosing && IsInGpose) - { - _logger.LogDebug("Gpose end"); - IsInGpose = false; - Mediator.Publish(new GposeEndMessage()); - } + // Checks on conditions + var shouldBeInGpose = _clientState.IsGPosing; + var shouldBeInCombat = _condition[ConditionFlag.InCombat] && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat; + var shouldBePerforming = _condition[ConditionFlag.Performing] && _playerPerformanceConfigService.Current.PauseWhilePerforming; + var shouldBeInInstance = _condition[ConditionFlag.BoundByDuty] && _playerPerformanceConfigService.Current.PauseInInstanceDuty; + var shouldBeInCutscene = _condition[ConditionFlag.WatchingCutscene]; - if ((_condition[ConditionFlag.InCombat]) && !IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat) - { - _logger.LogDebug("Combat start"); - IsInCombat = true; - Mediator.Publish(new CombatStartMessage()); - Mediator.Publish(new HaltScanMessage(nameof(IsInCombat))); - } - else if ((!_condition[ConditionFlag.InCombat]) && IsInCombat && !IsInInstance && _playerPerformanceConfigService.Current.PauseInCombat) - { - _logger.LogDebug("Combat end"); - IsInCombat = false; - Mediator.Publish(new CombatEndMessage()); - Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat))); - } - if (_condition[ConditionFlag.Performing] && !IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming) - { - _logger.LogDebug("Performance start"); - IsInCombat = true; - Mediator.Publish(new PerformanceStartMessage()); - Mediator.Publish(new HaltScanMessage(nameof(IsPerforming))); - } - else if (!_condition[ConditionFlag.Performing] && IsPerforming && _playerPerformanceConfigService.Current.PauseWhilePerforming) - { - _logger.LogDebug("Performance end"); - IsInCombat = false; - Mediator.Publish(new PerformanceEndMessage()); - Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming))); - } - if ((_condition[ConditionFlag.BoundByDuty]) && !IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty) - { - _logger.LogDebug("Instance start"); - IsInInstance = true; - Mediator.Publish(new InstanceOrDutyStartMessage()); - Mediator.Publish(new HaltScanMessage(nameof(IsInInstance))); - } - else if (((!_condition[ConditionFlag.BoundByDuty]) && IsInInstance && _playerPerformanceConfigService.Current.PauseInInstanceDuty) || ((_condition[ConditionFlag.BoundByDuty]) && IsInInstance && !_playerPerformanceConfigService.Current.PauseInInstanceDuty)) - { - _logger.LogDebug("Instance end"); - IsInInstance = false; - Mediator.Publish(new InstanceOrDutyEndMessage()); - Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance))); - } + // Gpose + HandleStateTransition(() => IsInGpose, v => IsInGpose = v, shouldBeInGpose, "Gpose", + onEnter: () => + { + Mediator.Publish(new GposeStartMessage()); + }, + onExit: () => + { + Mediator.Publish(new GposeEndMessage()); + }); - if (_condition[ConditionFlag.WatchingCutscene] && !IsInCutscene) - { - _logger.LogDebug("Cutscene start"); - IsInCutscene = true; - Mediator.Publish(new CutsceneStartMessage()); - Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene))); - } - else if (!_condition[ConditionFlag.WatchingCutscene] && IsInCutscene) - { - _logger.LogDebug("Cutscene end"); - IsInCutscene = false; - Mediator.Publish(new CutsceneEndMessage()); - Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene))); - } + // Combat + HandleStateTransition(() => IsInCombat, v => IsInCombat = v, shouldBeInCombat, "Combat", + onEnter: () => + { + Mediator.Publish(new CombatStartMessage()); + Mediator.Publish(new HaltScanMessage(nameof(IsInCombat))); + }, + onExit: () => + { + Mediator.Publish(new CombatEndMessage()); + Mediator.Publish(new ResumeScanMessage(nameof(IsInCombat))); + }); + + // Performance + HandleStateTransition(() => IsPerforming, v => IsPerforming = v, shouldBePerforming, "Performance", + onEnter: () => + { + Mediator.Publish(new PerformanceStartMessage()); + Mediator.Publish(new HaltScanMessage(nameof(IsPerforming))); + }, + onExit: () => + { + Mediator.Publish(new PerformanceEndMessage()); + Mediator.Publish(new ResumeScanMessage(nameof(IsPerforming))); + }); + + // Instance / Duty + HandleStateTransition(() => IsInInstance, v => IsInInstance = v, shouldBeInInstance, "Instance", + onEnter: () => + { + Mediator.Publish(new InstanceOrDutyStartMessage()); + Mediator.Publish(new HaltScanMessage(nameof(IsInInstance))); + }, + onExit: () => + { + Mediator.Publish(new InstanceOrDutyEndMessage()); + Mediator.Publish(new ResumeScanMessage(nameof(IsInInstance))); + }); + + // Cutscene + HandleStateTransition(() => IsInCutscene,v => IsInCutscene = v, shouldBeInCutscene, "Cutscene", + onEnter: () => + { + Mediator.Publish(new CutsceneStartMessage()); + Mediator.Publish(new HaltScanMessage(nameof(IsInCutscene))); + }, + onExit: () => + { + Mediator.Publish(new CutsceneEndMessage()); + Mediator.Publish(new ResumeScanMessage(nameof(IsInCutscene))); + }); if (IsInCutscene) { @@ -867,4 +862,31 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _delayedFrameworkUpdateCheck = DateTime.UtcNow; }); } + + /// + /// Handler for the transition of different states of game + /// + /// Get state of condition + /// Set state of condition + /// Correction of the state of the condition + /// Condition name + /// Function for on entering the state + /// Function for on leaving the state + private void HandleStateTransition(Func getState, Action setState, bool shouldBeActive, string stateName, System.Action onEnter, System.Action onExit) + { + var isActive = getState(); + + if (shouldBeActive && !isActive) + { + _logger.LogDebug("{stateName} start", stateName); + setState(true); + onEnter(); + } + else if (!shouldBeActive && isActive) + { + _logger.LogDebug("{stateName} end", stateName); + setState(false); + onExit(); + } + } } \ No newline at end of file diff --git a/LightlessSync/Services/LightlessProfileManager.cs b/LightlessSync/Services/LightlessProfileManager.cs index 0895078..7d60d66 100644 --- a/LightlessSync/Services/LightlessProfileManager.cs +++ b/LightlessSync/Services/LightlessProfileManager.cs @@ -3,6 +3,7 @@ using LightlessSync.API.Data.Comparer; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; +using LightlessSync.Services.Profiles; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using Serilog.Core; diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index ef31cec..756874b 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -123,6 +123,8 @@ public record GPoseLobbyReceiveWorldData(UserData UserData, WorldData WorldData) public record OpenCharaDataHubWithFilterMessage(UserData UserData) : MessageBase; public record EnableBroadcastMessage(string HashedCid, bool Enabled) : MessageBase; public record BroadcastStatusChangedMessage(bool Enabled, TimeSpan? Ttl) : MessageBase; +public record UserLeftSyncshell(string gid) : MessageBase; +public record UserJoinedSyncshell(string gid) : MessageBase; public record SyncshellBroadcastsUpdatedMessage : MessageBase; public record PairRequestReceivedMessage(string HashedCid, string Message) : MessageBase; public record PairRequestsUpdatedMessage : MessageBase; diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index 313eabe..f117da9 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -16,7 +16,6 @@ using LightlessSync.UtilsEnum.Enum; // Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! using Microsoft.Extensions.Logging; -using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; @@ -28,7 +27,6 @@ public unsafe class NameplateHandler : IMediatorSubscriber private readonly IAddonLifecycle _addonLifecycle; private readonly IGameGui _gameGui; private readonly IClientState _clientState; - private readonly DalamudUtilService _dalamudUtil; private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; @@ -46,17 +44,15 @@ public unsafe class NameplateHandler : IMediatorSubscriber internal const uint mNameplateNodeIDBase = 0x7D99D500; private const string DefaultLabelText = "LightFinder"; private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; - private const int _containerOffsetX = 50; private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); private ImmutableHashSet _activeBroadcastingCids = []; - public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, DalamudUtilService dalamudUtil, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairUiService pairUiService) + public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairUiService pairUiService) { _logger = logger; _addonLifecycle = addonLifecycle; _gameGui = gameGui; - _dalamudUtil = dalamudUtil; _configService = configService; _mediator = mediator; _clientState = clientState; @@ -118,7 +114,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber { if (args.Addon.Address == nint.Zero) { - _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); return; } @@ -177,7 +174,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber var currentHandle = _gameGui.GetAddonByName("NamePlate", 1); if (currentHandle.Address == nint.Zero) { - _logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available."); + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available."); return; } @@ -187,7 +185,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (_mpNameplateAddon != pCurrentNameplateAddon) { - _logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon); + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon); return; } @@ -197,7 +196,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber var pNameplateNode = GetNameplateComponentNode(i); if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null)) { - _logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i); continue; } @@ -210,12 +210,13 @@ public unsafe class NameplateHandler : IMediatorSubscriber if (pTextNode->AtkResNode.NextSiblingNode != null) pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; pNameplateNode->Component->UldManager.UpdateDrawNodeList(); - pTextNode->AtkResNode.Destroy(true); + pTextNode->AtkResNode.Destroy(free: true); _mTextNodes[i] = null; } catch (Exception e) { - _logger.LogError($"Unknown error while removing text node 0x{(IntPtr)pTextNode:X} for nameplate {i} on component node 0x{(IntPtr)pNameplateNode:X}:\n{e}"); + if (_logger.IsEnabled(LogLevel.Error)) + _logger.LogError("Unknown error while removing text node 0x{textNode} for nameplate {i} on component node 0x{nameplateNode}:\n{e}", (IntPtr)pTextNode, i, (IntPtr)pNameplateNode, e); } } } @@ -239,36 +240,40 @@ public unsafe class NameplateHandler : IMediatorSubscriber var currentHandle = _gameGui.GetAddonByName("NamePlate"); if (currentHandle.Address == nint.Zero) { - _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); return; } var currentAddon = (AddonNamePlate*)currentHandle.Address; if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) { - if (_mpNameplateAddon != null) - _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached:X}, current {Current:X}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); + if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); return; } var framework = Framework.Instance(); if (framework == null) { - _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); return; } var uiModule = framework->GetUIModule(); if (uiModule == null) { - _logger.LogDebug("UI module unavailable during nameplate update, skipping."); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("UI module unavailable during nameplate update, skipping."); return; } var ui3DModule = uiModule->GetUI3DModule(); if (ui3DModule == null) { - _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); return; } @@ -280,7 +285,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber var safeCount = System.Math.Min( ui3DModule->NamePlateObjectInfoCount, - vec.Length + vec.Length ); for (int i = 0; i < safeCount; ++i) @@ -347,7 +352,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pNode->AtkResNode.ToggleVisibility(enable: false); continue; } - + root->Component->UldManager.UpdateDrawNodeList(); bool isVisible = @@ -449,10 +454,6 @@ public unsafe class NameplateHandler : IMediatorSubscriber { _cachedNameplateTextOffsets[nameplateIndex] = textOffset; } - else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue) - { - textOffset = _cachedNameplateTextOffsets[nameplateIndex]; - } else { hasValidOffset = false; @@ -534,7 +535,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber pNode->EdgeColor.A = (byte)(edgeColor.W * 255); - if(!config.LightfinderLabelUseIcon) + if (!config.LightfinderLabelUseIcon) { pNode->AlignmentType = AlignmentType.Bottom; } @@ -642,10 +643,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber { return _mpNameplateAddon->NamePlateObjectArray[i]; } - else - { - return null; - } + return null; } private AtkComponentNode* GetNameplateComponentNode(int i) @@ -653,12 +651,12 @@ public unsafe class NameplateHandler : IMediatorSubscriber var nameplateObject = GetNameplateObject(i); return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; } + private HashSet VisibleUserIds => [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) .Select(u => (ulong)u.PlayerCharacterId)]; - public void FlagRefresh() { _needsLabelRefresh = true; @@ -680,7 +678,8 @@ public unsafe class NameplateHandler : IMediatorSubscriber return; _activeBroadcastingCids = newSet; - _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); FlagRefresh(); } diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index cb1a607..02b5b05 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -70,7 +70,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ { var notification = CreateNotification(title, message, type, duration, actions, soundEffectId); - if (_configService.Current.AutoDismissOnAction && notification.Actions.Any()) + if (_configService.Current.AutoDismissOnAction && notification.Actions.Count != 0) { WrapActionsWithAutoDismiss(notification); } @@ -115,7 +115,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ } } - private void DismissNotification(LightlessNotification notification) + private static void DismissNotification(LightlessNotification notification) { notification.IsDismissed = true; notification.IsAnimatingOut = true; @@ -219,10 +219,12 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationMessage(notification)); } - private string FormatDownloadCompleteMessage(string fileName, int fileCount) => - fileCount > 1 + private static string FormatDownloadCompleteMessage(string fileName, int fileCount) + { + return fileCount > 1 ? $"Downloaded {fileCount} files successfully." : $"Downloaded {fileName} successfully."; + } private List CreateDownloadCompleteActions(Action? onOpenFolder) { @@ -268,8 +270,10 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ Mediator.Publish(new LightlessNotificationMessage(notification)); } - private string FormatErrorMessage(string message, Exception? exception) => - exception != null ? $"{message}\n\nError: {exception.Message}" : message; + private static string FormatErrorMessage(string message, Exception? exception) + { + return exception != null ? $"{message}\n\nError: {exception.Message}" : message; + } private List CreateErrorActions(Action? onRetry, Action? onViewLog) { @@ -343,8 +347,9 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ return string.Join("\n", activeDownloads.Select(x => $"• {x.PlayerName}: {FormatDownloadStatus(x)}")); } - private string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) => - download.Status switch + private static string FormatDownloadStatus((string PlayerName, float Progress, string Status) download) + { + return download.Status switch { "downloading" => $"{download.Progress:P0}", "decompressing" => "decompressing", @@ -352,6 +357,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ "waiting" => "waiting for slot", _ => download.Status }; + } private TimeSpan GetDefaultDurationForType(NotificationType type) => type switch { @@ -500,13 +506,16 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ }); } - private Dalamud.Interface.ImGuiNotification.NotificationType - ConvertToDalamudNotificationType(NotificationType type) => type switch + private static Dalamud.Interface.ImGuiNotification.NotificationType + ConvertToDalamudNotificationType(NotificationType type) { - NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, - NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, - _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info - }; + return type switch + { + NotificationType.Error => Dalamud.Interface.ImGuiNotification.NotificationType.Error, + NotificationType.Warning => Dalamud.Interface.ImGuiNotification.NotificationType.Warning, + _ => Dalamud.Interface.ImGuiNotification.NotificationType.Info + }; + } private void ShowChat(NotificationMessage msg) { @@ -590,7 +599,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private void HandlePairRequestsUpdated(PairRequestsUpdatedMessage _) { var activeRequests = _pairRequestService.GetActiveRequests(); - var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(); + var activeRequestIds = activeRequests.Select(r => r.HashedCid).ToHashSet(StringComparer.Ordinal); // Dismiss notifications for requests that are no longer active (expired) var notificationsToRemove = _shownPairRequestNotifications @@ -607,7 +616,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private void HandlePairDownloadStatus(PairDownloadStatusMessage msg) { - var userDownloads = msg.DownloadStatus.Where(x => x.PlayerName != "Pair Queue").ToList(); + var userDownloads = msg.DownloadStatus.Where(x => !string.Equals(x.PlayerName, "Pair Queue", StringComparison.Ordinal)).ToList(); var totalProgress = userDownloads.Count > 0 ? userDownloads.Average(x => x.Progress) : 0f; var message = BuildPairDownloadMessage(userDownloads, msg.QueueWaiting); @@ -763,7 +772,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ return actions; } - private string GetUserDisplayName(UserData userData, string playerName) + private static string GetUserDisplayName(UserData userData, string playerName) { if (!string.IsNullOrEmpty(userData.Alias) && !string.Equals(userData.Alias, userData.UID, StringComparison.Ordinal)) { diff --git a/LightlessSync/Services/PairProcessing/PairProcessingLimiterSnapshot.cs b/LightlessSync/Services/PairProcessing/PairProcessingLimiterSnapshot.cs new file mode 100644 index 0000000..64cc6b0 --- /dev/null +++ b/LightlessSync/Services/PairProcessing/PairProcessingLimiterSnapshot.cs @@ -0,0 +1,9 @@ +using System.Runtime.InteropServices; + +namespace LightlessSync.Services.PairProcessing; + +[StructLayout(LayoutKind.Auto)] +public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting) +{ + public int Remaining => Math.Max(0, Limit - InFlight); +} diff --git a/LightlessSync/Services/PairProcessingLimiter.cs b/LightlessSync/Services/PairProcessingLimiter.cs index 239ba75..35b6d1c 100644 --- a/LightlessSync/Services/PairProcessingLimiter.cs +++ b/LightlessSync/Services/PairProcessingLimiter.cs @@ -1,15 +1,13 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase { - private const int HardLimit = 32; + private const int _hardLimit = 32; private readonly LightlessConfigService _configService; private readonly object _limitLock = new(); private readonly SemaphoreSlim _semaphore; @@ -24,8 +22,8 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase { _configService = configService; _currentLimit = CalculateLimit(); - var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : HardLimit; - _semaphore = new SemaphoreSlim(initialCount, HardLimit); + var initialCount = _configService.Current.EnablePairProcessingLimiter ? _currentLimit : _hardLimit; + _semaphore = new SemaphoreSlim(initialCount, _hardLimit); Mediator.Subscribe(this, _ => UpdateSemaphoreLimit()); } @@ -88,7 +86,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase if (!enabled) { - var releaseAmount = HardLimit - _semaphore.CurrentCount; + var releaseAmount = _hardLimit - _semaphore.CurrentCount; if (releaseAmount > 0) { TryReleaseSemaphore(releaseAmount); @@ -110,7 +108,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase var increment = desiredLimit - _currentLimit; _pendingIncrements += increment; - var available = HardLimit - _semaphore.CurrentCount; + var available = _hardLimit - _semaphore.CurrentCount; var toRelease = Math.Min(_pendingIncrements, available); if (toRelease > 0 && TryReleaseSemaphore(toRelease)) { @@ -148,7 +146,7 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase private int CalculateLimit() { var configured = _configService.Current.MaxConcurrentPairApplications; - return Math.Clamp(configured, 1, HardLimit); + return Math.Clamp(configured, 1, _hardLimit); } private bool TryReleaseSemaphore(int count = 1) @@ -248,8 +246,3 @@ public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase } } } - -public readonly record struct PairProcessingLimiterSnapshot(bool IsEnabled, int Limit, int InFlight, int Waiting) -{ - public int Remaining => Math.Max(0, Limit - InFlight); -} diff --git a/LightlessSync/Services/PerformanceCollectorService.cs b/LightlessSync/Services/PerformanceCollectorService.cs index 877cc1c..d2b4c46 100644 --- a/LightlessSync/Services/PerformanceCollectorService.cs +++ b/LightlessSync/Services/PerformanceCollectorService.cs @@ -135,13 +135,13 @@ public sealed class PerformanceCollectorService : IHostedService if (pastEntries.Any()) { - sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries.Last().Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); + sb.Append((" " + TimeSpan.FromTicks(pastEntries.LastOrDefault() == default ? 0 : pastEntries[^1].Item2).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); sb.Append('|'); sb.Append((" " + TimeSpan.FromTicks(pastEntries.Max(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); sb.Append('|'); sb.Append((" " + TimeSpan.FromTicks((long)pastEntries.Average(m => m.Item2)).TotalMilliseconds.ToString("0.00000", CultureInfo.InvariantCulture)).PadRight(15)); sb.Append('|'); - sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries.Last().Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' ')); + sb.Append((" " + (pastEntries.LastOrDefault() == default ? "-" : pastEntries[^1].Item1.ToString("HH:mm:ss.ffff", CultureInfo.InvariantCulture))).PadRight(15, ' ')); sb.Append('|'); sb.Append((" " + pastEntries.Count).PadRight(10)); sb.Append('|'); @@ -183,7 +183,7 @@ public sealed class PerformanceCollectorService : IHostedService { try { - var last = entries.Value.ToList().Last(); + var last = entries.Value.ToList()[^1]; if (last.Item1.AddMinutes(10) < TimeOnly.FromDateTime(DateTime.Now) && !PerformanceCounters.TryRemove(entries.Key, out _)) { _logger.LogDebug("Could not remove performance counter {counter}", entries.Key); diff --git a/LightlessSync/Services/LightlessGroupProfileData.cs b/LightlessSync/Services/Profiles/LightlessGroupProfileData.cs similarity index 86% rename from LightlessSync/Services/LightlessGroupProfileData.cs rename to LightlessSync/Services/Profiles/LightlessGroupProfileData.cs index eb77175..2866955 100644 --- a/LightlessSync/Services/LightlessGroupProfileData.cs +++ b/LightlessSync/Services/Profiles/LightlessGroupProfileData.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; - -namespace LightlessSync.Services; +namespace LightlessSync.Services.Profiles; public record LightlessGroupProfileData( bool IsDisabled, diff --git a/LightlessSync/Services/LightlessUserProfileData.cs b/LightlessSync/Services/Profiles/LightlessUserProfileData.cs similarity index 88% rename from LightlessSync/Services/LightlessUserProfileData.cs rename to LightlessSync/Services/Profiles/LightlessUserProfileData.cs index b4ba383..7e80b10 100644 --- a/LightlessSync/Services/LightlessUserProfileData.cs +++ b/LightlessSync/Services/Profiles/LightlessUserProfileData.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; - -namespace LightlessSync.Services; +namespace LightlessSync.Services.Profiles; public record LightlessUserProfileData( bool IsFlagged, diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 435d3c2..72681f7 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -22,7 +22,6 @@ public class UiFactory private readonly ServerConfigurationManager _serverConfigManager; private readonly LightlessProfileManager _lightlessProfileManager; private readonly PerformanceCollectorService _performanceCollectorService; - private readonly FileDialogManager _fileDialogManager; private readonly ProfileTagService _profileTagService; public UiFactory( @@ -34,7 +33,6 @@ public class UiFactory ServerConfigurationManager serverConfigManager, LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, - FileDialogManager fileDialogManager, ProfileTagService profileTagService) { _loggerFactory = loggerFactory; @@ -45,7 +43,6 @@ public class UiFactory _serverConfigManager = serverConfigManager; _lightlessProfileManager = lightlessProfileManager; _performanceCollectorService = performanceCollectorService; - _fileDialogManager = fileDialogManager; _profileTagService = profileTagService; } @@ -59,8 +56,7 @@ public class UiFactory _pairUiService, dto, _performanceCollectorService, - _lightlessProfileManager, - _fileDialogManager); + _lightlessProfileManager); } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) diff --git a/LightlessSync/Services/XivDataAnalyzer.cs b/LightlessSync/Services/XivDataAnalyzer.cs index db721a2..9d32883 100644 --- a/LightlessSync/Services/XivDataAnalyzer.cs +++ b/LightlessSync/Services/XivDataAnalyzer.cs @@ -46,7 +46,7 @@ public sealed class XivDataAnalyzer if (handle->FileName.Length > 1024) continue; var skeletonName = handle->FileName.ToString(); if (string.IsNullOrEmpty(skeletonName)) continue; - outputIndices[skeletonName] = new(); + outputIndices[skeletonName] = []; for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++) { var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String; @@ -70,7 +70,7 @@ public sealed class XivDataAnalyzer var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); if (cacheEntity == null) return null; - using BinaryReader reader = new BinaryReader(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read)); + using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read)); // most of this shit is from vfxeditor, surely nothing will change in the pap format :copium: reader.ReadInt32(); // ignore @@ -177,17 +177,18 @@ public sealed class XivDataAnalyzer } long tris = 0; - for (int i = 0; i < file.LodCount; i++) + foreach (var lod in file.Lods) { try { - var meshIdx = file.Lods[i].MeshIndex; - var meshCnt = file.Lods[i].MeshCount; + var meshIdx = lod.MeshIndex; + var meshCnt = lod.MeshCount; + tris = file.Meshes.Skip(meshIdx).Take(meshCnt).Sum(p => p.IndexCount) / 3; } catch (Exception ex) { - _logger.LogDebug(ex, "Could not load lod mesh {mesh} from path {path}", i, filePath); + _logger.LogDebug(ex, "Could not load lod mesh {mesh} from path {path}", lod.MeshIndex, filePath); continue; } diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs index 1f4eb37..5540b02 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/BroadcastUI.cs @@ -2,6 +2,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; +using Dalamud.Utility; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; @@ -21,7 +22,7 @@ namespace LightlessSync.UI private readonly UiSharedService _uiSharedService; private readonly BroadcastScannerService _broadcastScannerService; - private IReadOnlyList _allSyncshells; + private IReadOnlyList _allSyncshells = Array.Empty(); private string _userUid = string.Empty; private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); @@ -191,7 +192,7 @@ namespace LightlessSync.UI ImGui.PopStyleVar(); ImGuiHelpers.ScaledDummy(3f); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); if (_configService.Current.BroadcastEnabled) { @@ -287,7 +288,7 @@ namespace LightlessSync.UI _uiSharedService.MediumText("Syncshell Finder", UIColors.Get("PairBlue")); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); ImGui.PushTextWrapPos(); ImGui.Text("Allow your owned Syncshell to be indexed by the Nearby Syncshell Finder."); @@ -295,7 +296,7 @@ namespace LightlessSync.UI ImGui.PopTextWrapPos(); ImGuiHelpers.ScaledDummy(0.2f); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; bool isBroadcasting = _broadcastService.IsBroadcasting; diff --git a/LightlessSync/UI/CharaDataHubUi.Functions.cs b/LightlessSync/UI/CharaDataHubUi.Functions.cs index 665e640..ccef174 100644 --- a/LightlessSync/UI/CharaDataHubUi.Functions.cs +++ b/LightlessSync/UI/CharaDataHubUi.Functions.cs @@ -13,13 +13,15 @@ internal sealed partial class CharaDataHubUi AccessTypeDto.AllPairs => "All Pairs", AccessTypeDto.ClosePairs => "Direct Pairs", AccessTypeDto.Individuals => "Specified", - AccessTypeDto.Public => "Everyone" + AccessTypeDto.Public => "Everyone", + _ => throw new NotSupportedException() }; private static string GetShareTypeString(ShareTypeDto dto) => dto switch { ShareTypeDto.Private => "Code Only", - ShareTypeDto.Shared => "Shared" + ShareTypeDto.Shared => "Shared", + _ => throw new NotSupportedException() }; private static string GetWorldDataTooltipText(PoseEntryExtended poseEntry) @@ -31,7 +33,7 @@ internal sealed partial class CharaDataHubUi private void GposeMetaInfoAction(Action gposeActionDraw, string actionDescription, CharaDataMetaInfoExtendedDto? dto, bool hasValidGposeTarget, bool isSpawning) { - StringBuilder sb = new StringBuilder(); + StringBuilder sb = new(); sb.AppendLine(actionDescription); bool isDisabled = false; diff --git a/LightlessSync/UI/CharaDataHubUi.McdOnline.cs b/LightlessSync/UI/CharaDataHubUi.McdOnline.cs index dc6b572..0219205 100644 --- a/LightlessSync/UI/CharaDataHubUi.McdOnline.cs +++ b/LightlessSync/UI/CharaDataHubUi.McdOnline.cs @@ -406,7 +406,7 @@ internal sealed partial class CharaDataHubUi { _uiSharedService.BigText("Poses"); var poseCount = updateDto.PoseList.Count(); - using (ImRaii.Disabled(poseCount >= maxPoses)) + using (ImRaii.Disabled(poseCount >= _maxPoses)) { if (_uiSharedService.IconTextButton(FontAwesomeIcon.Plus, "Add new Pose")) { @@ -414,8 +414,8 @@ internal sealed partial class CharaDataHubUi } } ImGui.SameLine(); - using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"), poseCount == maxPoses)) - ImGui.TextUnformatted($"{poseCount}/{maxPoses} poses attached"); + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"), poseCount == _maxPoses)) + ImGui.TextUnformatted($"{poseCount}/{_maxPoses} poses attached"); ImGuiHelpers.ScaledDummy(5); using var indent = ImRaii.PushIndent(10f); @@ -463,12 +463,16 @@ internal sealed partial class CharaDataHubUi else { var desc = pose.Description; - if (ImGui.InputTextWithHint("##description", "Description", ref desc, 100)) + if (desc != null) { - pose.Description = desc; - updateDto.UpdatePoseList(); + if (ImGui.InputTextWithHint("##description", "Description", ref desc, 100)) + { + pose.Description = desc; + updateDto.UpdatePoseList(); + } + ImGui.SameLine(); } - ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Trash, "Delete")) { updateDto.RemovePose(pose); diff --git a/LightlessSync/UI/CharaDataHubUi.cs b/LightlessSync/UI/CharaDataHubUi.cs index b50b819..fdaa27c 100644 --- a/LightlessSync/UI/CharaDataHubUi.cs +++ b/LightlessSync/UI/CharaDataHubUi.cs @@ -21,7 +21,7 @@ namespace LightlessSync.UI; internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase { - private const int maxPoses = 10; + private const int _maxPoses = 10; private readonly CharaDataManager _charaDataManager; private readonly CharaDataNearbyManager _charaDataNearbyManager; private readonly CharaDataConfigService _configService; @@ -33,7 +33,7 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiSharedService; private CancellationTokenSource _closalCts = new(); private bool _disableUI = false; - private CancellationTokenSource _disposalCts = new(); + private readonly CancellationTokenSource _disposalCts = new(); private string _exportDescription = string.Empty; private string _filterCodeNote = string.Empty; private string _filterDescription = string.Empty; @@ -145,6 +145,8 @@ internal sealed partial class CharaDataHubUi : WindowMediatorSubscriberBase { _closalCts.CancelDispose(); _disposalCts.CancelDispose(); + _disposalCts.Dispose(); + _closalCts.Dispose(); } base.Dispose(disposing); diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 8adb54a..4c46fd4 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -27,6 +27,7 @@ using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Reflection; +using System.Runtime.InteropServices; namespace LightlessSync.UI; @@ -310,7 +311,7 @@ public class CompactUi : WindowMediatorSubscriberBase private void DrawPairs() { - var ySize = _transferPartHeight == 0 + float ySize = Math.Abs(_transferPartHeight) < 0.0001f ? 1 : (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y + ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY(); @@ -510,6 +511,7 @@ public class CompactUi : WindowMediatorSubscriberBase return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); } + [StructLayout(LayoutKind.Auto)] private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) { public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; @@ -590,7 +592,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.PopStyleColor(); ImGuiHelpers.ScaledDummy(0.2f); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); if (_configService.Current.BroadcastEnabled) { diff --git a/LightlessSync/UI/Components/DrawFolderGroup.cs b/LightlessSync/UI/Components/DrawFolderGroup.cs index ef9fdfb..4e8a6a1 100644 --- a/LightlessSync/UI/Components/DrawFolderGroup.cs +++ b/LightlessSync/UI/Components/DrawFolderGroup.cs @@ -119,6 +119,7 @@ public class DrawFolderGroup : DrawFolderBase if (_uiSharedService.IconTextButton(FontAwesomeIcon.ArrowCircleLeft, "Leave Syncshell", menuWidth, true) && UiSharedService.CtrlPressed()) { _ = _apiController.GroupLeave(_groupFullInfoDto); + _lightlessMediator.Publish(new UserLeftSyncshell(_groupFullInfoDto.GID)); ImGui.CloseCurrentPopup(); } UiSharedService.AttachToolTip("Hold CTRL and click to leave this Syncshell" + (!string.Equals(_groupFullInfoDto.OwnerUID, _apiController.UID, StringComparison.Ordinal) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index aa3132a..1902124 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -5,6 +5,7 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; @@ -22,6 +23,7 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); + private readonly Dictionary _smoothed = []; private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; @@ -203,8 +205,18 @@ public class DownloadUi : WindowMediatorSubscriberBase foreach (var transfer in _currentDownloads.ToList()) { - var screenPos = _dalamudUtilService.WorldToScreen(transfer.Key.GetGameObject()); - if (screenPos == Vector2.Zero) continue; + var transferKey = transfer.Key; + var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); + //If RawPos is zero, remove it from smoothed dictionary + if (rawPos == Vector2.Zero) + { + _smoothed.Remove(transferKey); + continue; + } + //Smoothing out the movement and fix jitter around the position. + Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos : rawPos; + _smoothed[transferKey] = screenPos; + var totalBytes = transfer.Value.Sum(c => c.Value.TotalBytes); var transferredBytes = transfer.Value.Sum(c => c.Value.TransferredBytes); diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 8251fb2..5bff130 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -347,7 +347,7 @@ public sealed class DtrEntry : IDisposable, IHostedService try { var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult(); - var hashedCid = cid.ToString().GetHash256(); + var hashedCid = cid.ToString().GetBlake3Hash(); _localHashedCid = hashedCid; _localHashedCidFetchedAt = now; return hashedCid; @@ -445,7 +445,7 @@ public sealed class DtrEntry : IDisposable, IHostedService return ($"{icon} OFF", colors, tooltip.ToString()); } - private (string, Colors, string) FormatTooltip(string title, IEnumerable names, string icon, Colors color) + private static (string, Colors, string) FormatTooltip(string title, IEnumerable names, string icon, Colors color) { var list = names.Where(x => !string.IsNullOrEmpty(x)).ToList(); var tooltip = new StringBuilder() diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index 57f6c2f..3d593d8 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -9,6 +9,7 @@ using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.Profiles; using LightlessSync.UI.Tags; using LightlessSync.Utils; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 62d4bde..7c55775 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -25,6 +25,7 @@ using System.IO; using System.Numerics; using System.Threading.Tasks; using System.Linq; +using LightlessSync.Services.Profiles; namespace LightlessSync.UI; @@ -91,14 +92,13 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private bool _wasOpen; private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); - private bool vanityInitialized; // useless for now private bool textEnabled; private bool glowEnabled; private Vector4 textColor; private Vector4 glowColor; - private record VanityState(bool TextEnabled, bool GlowEnabled, Vector4 TextColor, Vector4 GlowColor); - private VanityState _savedVanity; + private sealed record VanityState(bool TextEnabled, bool GlowEnabled, Vector4 TextColor, Vector4 GlowColor); + private VanityState? _savedVanity; public EditProfileUi(ILogger logger, LightlessMediator mediator, ApiController apiController, UiSharedService uiSharedService, FileDialogManager fileDialogManager, @@ -161,7 +161,6 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase glowColor = glowEnabled ? UIColors.HexToRgba(_apiController.TextGlowColorHex!) : Vector4.Zero; _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); - vanityInitialized = true; } public override async void OnOpen() diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index 0d45938..b3f90d0 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -177,13 +177,11 @@ public class IdDisplayHandler Vector2 itemMin; Vector2 itemMax; - Vector2 textSize; using (ImRaii.PushFont(font, textIsUid)) { SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID); itemMin = ImGui.GetItemRectMin(); itemMax = ImGui.GetItemRectMax(); - //textSize = itemMax - itemMin; } if (useHighlight) @@ -227,7 +225,7 @@ public class IdDisplayHandler var nameRectMax = ImGui.GetItemRectMax(); if (ImGui.IsItemHovered()) { - if (!string.Equals(_lastMouseOverUid, id)) + if (!string.Equals(_lastMouseOverUid, id, StringComparison.Ordinal)) { _popupTime = DateTime.UtcNow.AddSeconds(_lightlessConfigService.Current.ProfileDelay); } @@ -248,7 +246,7 @@ public class IdDisplayHandler } else { - if (string.Equals(_lastMouseOverUid, id)) + if (string.Equals(_lastMouseOverUid, id, StringComparison.Ordinal)) { _mediator.Publish(new ProfilePopoutToggle(Pair: null)); _lastMouseOverUid = string.Empty; diff --git a/LightlessSync/UI/IntroUI.cs b/LightlessSync/UI/IntroUI.cs index 470cadb..97935c2 100644 --- a/LightlessSync/UI/IntroUI.cs +++ b/LightlessSync/UI/IntroUI.cs @@ -267,7 +267,7 @@ public partial class IntroUi : WindowMediatorSubscriberBase { UiSharedService.ColorTextWrapped("Your secret key must be exactly 64 characters long. Don't enter your Lodestone auth here.", ImGuiColors.DalamudRed); } - else if (_secretKey.Length == 64 && !HexRegex().IsMatch(_secretKey)) + else if (_secretKey.Length == 64 && !SecretRegex().IsMatch(_secretKey)) { UiSharedService.ColorTextWrapped("Your secret key can only contain ABCDEF and the numbers 0-9.", ImGuiColors.DalamudRed); } @@ -360,6 +360,6 @@ public partial class IntroUi : WindowMediatorSubscriberBase _tosParagraphs = [Strings.ToS.Paragraph1, Strings.ToS.Paragraph2, Strings.ToS.Paragraph3, Strings.ToS.Paragraph4, Strings.ToS.Paragraph5, Strings.ToS.Paragraph6]; } - [GeneratedRegex("^([A-F0-9]{2})+")] - private static partial Regex HexRegex(); + [GeneratedRegex("^[A-F0-9]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant)] + private static partial Regex SecretRegex(); } \ No newline at end of file diff --git a/LightlessSync/UI/JoinSyncshellUI.cs b/LightlessSync/UI/JoinSyncshellUI.cs index b02a84e..989fa07 100644 --- a/LightlessSync/UI/JoinSyncshellUI.cs +++ b/LightlessSync/UI/JoinSyncshellUI.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Enum; @@ -174,6 +173,7 @@ internal class JoinSyncshellUI : WindowMediatorSubscriberBase joinPermissions.SetDisableAnimations(_ownPermissions.DisableGroupAnimations); joinPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_groupJoinInfo.Group, _previousPassword, joinPermissions)); + Mediator.Publish(new UserJoinedSyncshell(_groupJoinInfo.Group.GID)); IsOpen = false; } } diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 8cb6922..bdbe8df 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -1,8 +1,6 @@ using Dalamud.Interface; -using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; -using Dalamud.Interface.Windowing; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; @@ -27,11 +25,11 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private const float _titleMessageSpacing = 4f; private const float _actionButtonSpacing = 8f; - private readonly List _notifications = new(); + private readonly List _notifications = []; private readonly object _notificationLock = new(); private readonly LightlessConfigService _configService; - private readonly Dictionary _notificationYOffsets = new(); - private readonly Dictionary _notificationTargetYOffsets = new(); + private readonly Dictionary _notificationYOffsets = []; + private readonly Dictionary _notificationTargetYOffsets = []; public LightlessNotificationUi(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) @@ -45,7 +43,6 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoBackground | ImGuiWindowFlags.NoCollapse | - ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.AlwaysAutoResize; @@ -68,7 +65,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase { lock (_notificationLock) { - var existingNotification = _notifications.FirstOrDefault(n => n.Id == notification.Id); + var existingNotification = _notifications.FirstOrDefault(n => string.Equals(n.Id, notification.Id, StringComparison.Ordinal)); if (existingNotification != null) { UpdateExistingNotification(existingNotification, notification); @@ -103,7 +100,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase { lock (_notificationLock) { - var notification = _notifications.FirstOrDefault(n => n.Id == id); + var notification = _notifications.FirstOrDefault(n => string.Equals(n.Id, id, StringComparison.Ordinal)); if (notification != null) { StartOutAnimation(notification); @@ -122,13 +119,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private void StartOutAnimation(LightlessNotification notification) + private static void StartOutAnimation(LightlessNotification notification) { notification.IsAnimatingOut = true; notification.IsAnimatingIn = false; } - private bool ShouldRemoveNotification(LightlessNotification notification) => + private static bool ShouldRemoveNotification(LightlessNotification notification) => notification.IsAnimatingOut && notification.AnimationProgress <= 0.01f; protected override void DrawInternal() @@ -185,7 +182,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ImGui.SetCursorPosY(startY + yOffset); } - DrawNotification(notification, i); + DrawNotification(notification); } } @@ -304,7 +301,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return corner == NotificationCorner.Left ? new Vector2(-distance, 0) : new Vector2(distance, 0); } - private void DrawNotification(LightlessNotification notification, int index) + private void DrawNotification(LightlessNotification notification) { var alpha = notification.AnimationProgress; if (alpha <= 0f) return; @@ -339,7 +336,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase var bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered()); var accentColor = GetNotificationAccentColor(notification.Type); accentColor.W *= alpha; - + DrawShadow(drawList, windowPos, windowSize, alpha); HandleClickToDismiss(notification); DrawBackground(drawList, windowPos, windowSize, bgColor); @@ -370,7 +367,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return bgColor; } - private void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha) + private static void DrawShadow(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float alpha) { var shadowOffset = new Vector2(1f, 1f); var shadowColor = new Vector4(0f, 0f, 0f, 0.4f * alpha); @@ -384,9 +381,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private void HandleClickToDismiss(LightlessNotification notification) { - if (ImGui.IsWindowHovered() && + var pos = ImGui.GetWindowPos(); + var size = ImGui.GetWindowSize(); + bool hovered = ImGui.IsMouseHoveringRect(pos, new Vector2(pos.X + size.X, pos.Y + size.Y)); + + if ((hovered || ImGui.IsWindowHovered()) && _configService.Current.DismissNotificationOnClick && - !notification.Actions.Any() && + notification.Actions.Count == 0 && ImGui.IsMouseClicked(ImGuiMouseButton.Left)) { notification.IsDismissed = true; @@ -394,7 +395,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor) + private static void DrawBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, Vector4 bgColor) { drawList.AddRectFilled( windowPos, @@ -431,14 +432,14 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ); } - private void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) + private static void DrawDurationProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { var progress = CalculateDurationProgress(notification); var progressBarColor = UIColors.Get("LightlessBlue"); var progressHeight = 2f; var progressY = windowPos.Y + windowSize.Y - progressHeight; var progressWidth = windowSize.X * progress; - + DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha); if (progress > 0) @@ -447,7 +448,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) + private static void DrawDownloadProgressBar(LightlessNotification notification, float alpha, Vector2 windowPos, Vector2 windowSize, ImDrawListPtr drawList) { var progress = Math.Clamp(notification.Progress, 0f, 1f); var progressBarColor = UIColors.Get("LightlessGreen"); @@ -455,7 +456,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase // Position above the duration bar (2px duration bar + 1px spacing) var progressY = windowPos.Y + windowSize.Y - progressHeight - 3f; var progressWidth = windowSize.X * progress; - + DrawProgressBackground(drawList, windowPos, windowSize, progressY, progressHeight, progressBarColor, alpha); if (progress > 0) @@ -464,14 +465,14 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private float CalculateDurationProgress(LightlessNotification notification) + private static float CalculateDurationProgress(LightlessNotification notification) { // Calculate duration timer progress var elapsed = DateTime.UtcNow - notification.CreatedAt; return Math.Min(1.0f, (float)(elapsed.TotalSeconds / notification.Duration.TotalSeconds)); } - private void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha) + private static void DrawProgressBackground(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize, float progressY, float progressHeight, Vector4 progressBarColor, float alpha) { var bgProgressColor = new Vector4(progressBarColor.X * 0.3f, progressBarColor.Y * 0.3f, progressBarColor.Z * 0.3f, 0.5f * alpha); drawList.AddRectFilled( @@ -482,7 +483,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase ); } - private void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha) + private static void DrawProgressForeground(ImDrawListPtr drawList, Vector2 windowPos, float progressY, float progressHeight, float progressWidth, Vector4 progressBarColor, float alpha) { var progressColor = progressBarColor; progressColor.W *= alpha; @@ -512,13 +513,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private float CalculateContentWidth(float windowWidth) => + private static float CalculateContentWidth(float windowWidth) => windowWidth - (_contentPaddingX * 2); - private bool HasActions(LightlessNotification notification) => + private static bool HasActions(LightlessNotification notification) => notification.Actions.Count > 0; - private void PositionActionsAtBottom(float windowHeight) + private static void PositionActionsAtBottom(float windowHeight) { var actionHeight = ImGui.GetFrameHeight(); var bottomY = windowHeight - _contentPaddingY - actionHeight; @@ -546,7 +547,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return $"[{timestamp}] {notification.Title}"; } - private float DrawWrappedText(string text, float wrapWidth) + private static float DrawWrappedText(string text, float wrapWidth) { ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + wrapWidth); var startY = ImGui.GetCursorPosY(); @@ -556,7 +557,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return height; } - private void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha) + private static void DrawMessage(LightlessNotification notification, Vector2 contentPos, float contentWidth, float titleHeight, float alpha) { if (string.IsNullOrEmpty(notification.Message)) return; @@ -591,13 +592,13 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private float CalculateActionButtonWidth(int actionCount, float availableWidth) + private static float CalculateActionButtonWidth(int actionCount, float availableWidth) { var totalSpacing = (actionCount - 1) * _actionButtonSpacing; return (availableWidth - totalSpacing) / actionCount; } - private void PositionActionButton(int index, float startX, float buttonWidth) + private static void PositionActionButton(int index, float startX, float buttonWidth) { var xPosition = startX + index * (buttonWidth + _actionButtonSpacing); ImGui.SetCursorPosX(xPosition); @@ -625,7 +626,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase if (action.Icon != FontAwesomeIcon.None) { - buttonPressed = DrawIconTextButton(action.Icon, action.Label, buttonWidth, alpha); + buttonPressed = DrawIconTextButton(action.Icon, action.Label, buttonWidth); } else { @@ -650,10 +651,10 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase } } - private bool DrawIconTextButton(FontAwesomeIcon icon, string text, float width, float alpha) + private static bool DrawIconTextButton(FontAwesomeIcon icon, string text, float width) { var drawList = ImGui.GetWindowDrawList(); - var cursorPos = ImGui.GetCursorScreenPos(); + ImGui.GetCursorScreenPos(); var frameHeight = ImGui.GetFrameHeight(); Vector2 iconSize; @@ -729,7 +730,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return ImGui.CalcTextSize(titleText, true, contentWidth).Y; } - private float CalculateMessageHeight(LightlessNotification notification, float contentWidth) + private static float CalculateMessageHeight(LightlessNotification notification, float contentWidth) { if (string.IsNullOrEmpty(notification.Message)) return 0f; @@ -737,7 +738,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase return 4f + messageHeight; } - private Vector4 GetNotificationAccentColor(NotificationType type) + private static Vector4 GetNotificationAccentColor(NotificationType type) { return type switch { diff --git a/LightlessSync/UI/Models/Changelog.cs b/LightlessSync/UI/Models/Changelog.cs deleted file mode 100644 index 23d26c4..0000000 --- a/LightlessSync/UI/Models/Changelog.cs +++ /dev/null @@ -1,43 +0,0 @@ -namespace LightlessSync.UI.Models -{ - public class ChangelogFile - { - public string Tagline { get; init; } = string.Empty; - public string Subline { get; init; } = string.Empty; - public List Changelog { get; init; } = new(); - public List? Credits { get; init; } - } - - public class ChangelogEntry - { - public string Name { get; init; } = string.Empty; - public string Date { get; init; } = string.Empty; - public string Tagline { get; init; } = string.Empty; - public bool? IsCurrent { get; init; } - public string? Message { get; init; } - public List? Versions { get; init; } - } - - public class ChangelogVersion - { - public string Number { get; init; } = string.Empty; - public List Items { get; init; } = new(); - } - - public class CreditCategory - { - public string Category { get; init; } = string.Empty; - public List Items { get; init; } = new(); - } - - public class CreditItem - { - public string Name { get; init; } = string.Empty; - public string Role { get; init; } = string.Empty; - } - - public class CreditsFile - { - public List Credits { get; init; } = new(); - } -} \ No newline at end of file diff --git a/LightlessSync/UI/Models/ChangelogEntry.cs b/LightlessSync/UI/Models/ChangelogEntry.cs new file mode 100644 index 0000000..919a6da --- /dev/null +++ b/LightlessSync/UI/Models/ChangelogEntry.cs @@ -0,0 +1,12 @@ +namespace LightlessSync.UI.Models +{ + public class ChangelogEntry + { + public string Name { get; init; } = string.Empty; + public string Date { get; init; } = string.Empty; + public string Tagline { get; init; } = string.Empty; + public bool? IsCurrent { get; init; } + public string? Message { get; init; } + public List? Versions { get; init; } + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/ChangelogFile.cs b/LightlessSync/UI/Models/ChangelogFile.cs new file mode 100644 index 0000000..37997c8 --- /dev/null +++ b/LightlessSync/UI/Models/ChangelogFile.cs @@ -0,0 +1,10 @@ +namespace LightlessSync.UI.Models +{ + public class ChangelogFile + { + public string Tagline { get; init; } = string.Empty; + public string Subline { get; init; } = string.Empty; + public List Changelog { get; init; } = new(); + public List? Credits { get; init; } + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/ChangelogVersion.cs b/LightlessSync/UI/Models/ChangelogVersion.cs new file mode 100644 index 0000000..b70ace6 --- /dev/null +++ b/LightlessSync/UI/Models/ChangelogVersion.cs @@ -0,0 +1,8 @@ +namespace LightlessSync.UI.Models +{ + public class ChangelogVersion + { + public string Number { get; init; } = string.Empty; + public List Items { get; init; } = []; + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/CreditCategory.cs b/LightlessSync/UI/Models/CreditCategory.cs new file mode 100644 index 0000000..5b25cca --- /dev/null +++ b/LightlessSync/UI/Models/CreditCategory.cs @@ -0,0 +1,8 @@ +namespace LightlessSync.UI.Models +{ + public class CreditCategory + { + public string Category { get; init; } = string.Empty; + public List Items { get; init; } = []; + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/CreditItem.cs b/LightlessSync/UI/Models/CreditItem.cs new file mode 100644 index 0000000..ae0c4be --- /dev/null +++ b/LightlessSync/UI/Models/CreditItem.cs @@ -0,0 +1,8 @@ +namespace LightlessSync.UI.Models +{ + public class CreditItem + { + public string Name { get; init; } = string.Empty; + public string Role { get; init; } = string.Empty; + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/CreditsFile.cs b/LightlessSync/UI/Models/CreditsFile.cs new file mode 100644 index 0000000..b6b6a83 --- /dev/null +++ b/LightlessSync/UI/Models/CreditsFile.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.UI.Models +{ + public class CreditsFile + { + public List Credits { get; init; } = []; + } +} \ No newline at end of file diff --git a/LightlessSync/UI/Models/LightlessNotification.cs b/LightlessSync/UI/Models/LightlessNotification.cs index 3c6edea..4ae49a4 100644 --- a/LightlessSync/UI/Models/LightlessNotification.cs +++ b/LightlessSync/UI/Models/LightlessNotification.cs @@ -1,7 +1,7 @@ -using Dalamud.Interface; using LightlessSync.LightlessConfiguration.Models; -using System.Numerics; + namespace LightlessSync.UI.Models; + public class LightlessNotification { public string Id { get; set; } = Guid.NewGuid().ToString(); @@ -20,13 +20,3 @@ public class LightlessNotification public bool IsAnimatingOut { get; set; } = false; public uint? SoundEffectId { get; set; } = null; } -public class LightlessNotificationAction -{ - public string Id { get; set; } = Guid.NewGuid().ToString(); - public string Label { get; set; } = string.Empty; - public FontAwesomeIcon Icon { get; set; } = FontAwesomeIcon.None; - public Vector4 Color { get; set; } = Vector4.One; - public Action OnClick { get; set; } = _ => { }; - public bool IsPrimary { get; set; } = false; - public bool IsDestructive { get; set; } = false; -} \ No newline at end of file diff --git a/LightlessSync/UI/Models/LightlessNotificationAction.cs b/LightlessSync/UI/Models/LightlessNotificationAction.cs new file mode 100644 index 0000000..7c9fd53 --- /dev/null +++ b/LightlessSync/UI/Models/LightlessNotificationAction.cs @@ -0,0 +1,15 @@ +using Dalamud.Interface; +using System.Numerics; + +namespace LightlessSync.UI.Models; + +public class LightlessNotificationAction +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Label { get; set; } = string.Empty; + public FontAwesomeIcon Icon { get; set; } = FontAwesomeIcon.None; + public Vector4 Color { get; set; } = Vector4.One; + public Action OnClick { get; set; } = _ => { }; + public bool IsPrimary { get; set; } = false; + public bool IsDestructive { get; set; } = false; +} \ No newline at end of file diff --git a/LightlessSync/UI/Services/PairUiService.cs b/LightlessSync/UI/Services/PairUiService.cs index 5d38aec..290a7fb 100644 --- a/LightlessSync/UI/Services/PairUiService.cs +++ b/LightlessSync/UI/Services/PairUiService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; using LightlessSync.API.Dto.Group; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Pairs; diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1934d85..c0f0a68 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -612,7 +612,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - private bool DrawStyleResetButton(string key, bool hasOverride, string? tooltipOverride = null) + private static bool DrawStyleResetButton(string key, bool hasOverride, string? tooltipOverride = null) { using var id = ImRaii.PushId($"reset-{key}"); using var disabled = ImRaii.Disabled(!hasOverride); @@ -736,7 +736,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Controls how many uploads can run at once."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); if (ImGui.Checkbox("Enable Pair Download Limiter", ref limitPairApplications)) { @@ -783,7 +783,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.TextColored(ImGuiColors.DalamudGrey, "Pair apply limiter is disabled."); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 2f); if (ImGui.Checkbox("Use Alternative Upload Method", ref useAlternativeUpload)) { @@ -899,13 +899,10 @@ public class SettingsUi : WindowMediatorSubscriberBase using var tree = ImRaii.TreeNode("Speed Test to Servers"); if (tree) { - if (_downloadServersTask == null || ((_downloadServersTask?.IsCompleted ?? false) && - (!_downloadServersTask?.IsCompletedSuccessfully ?? false))) + if ((_downloadServersTask == null || ((_downloadServersTask?.IsCompleted ?? false) && + (!_downloadServersTask?.IsCompletedSuccessfully ?? false))) && _uiShared.IconTextButton(FontAwesomeIcon.GroupArrowsRotate, "Update Download Server List")) { - if (_uiShared.IconTextButton(FontAwesomeIcon.GroupArrowsRotate, "Update Download Server List")) - { - _downloadServersTask = GetDownloadServerList(); - } + _downloadServersTask = GetDownloadServerList(); } if (_downloadServersTask != null && _downloadServersTask.IsCompleted && @@ -1136,9 +1133,9 @@ public class SettingsUi : WindowMediatorSubscriberBase .DeserializeAsync>(await result.Content.ReadAsStreamAsync().ConfigureAwait(false)) .ConfigureAwait(false); } - catch (Exception ex) + catch (Exception) { - _logger.LogWarning(ex, "Failed to get download server list"); + _logger.LogWarning("Failed to get download server list"); throw; } } @@ -1219,7 +1216,7 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.TooltipSeparator + "Keeping LOD enabled can lead to more crashes. Use at your own risk."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 2f); } private void DrawFileStorageSettings() @@ -1421,7 +1418,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); ImGui.TreePop(); } @@ -1453,7 +1450,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } catch (IOException ex) { - _logger.LogWarning(ex, $"Could not delete file {file} because it is in use."); + _logger.LogWarning(ex, "Could not delete file {file} because it is in use.", file); } } @@ -1487,7 +1484,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndDisabled(); ImGui.Unindent(); - _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); ImGui.TreePop(); } } @@ -1500,8 +1497,6 @@ public class SettingsUi : WindowMediatorSubscriberBase } _lastTab = "General"; - //UiSharedService.FontText("Experimental", _uiShared.UidFont); - //ImGui.Separator(); _uiShared.UnderlinedBigText("General Settings", UIColors.Get("LightlessBlue")); ImGui.Dummy(new Vector2(10)); @@ -1539,7 +1534,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGuiColors.DalamudRed); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -1567,7 +1562,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( "This will automatically populate user notes using the first encountered player name if the note was not set prior"); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -1635,7 +1630,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -1675,7 +1670,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } _uiShared.DrawHelpText("When enabled, Lightfinder will automatically turn on after reconnecting to the Lightless server."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Lightfinder Nameplate Colors"); if (ImGui.BeginTable("##LightfinderColorTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit)) @@ -1731,7 +1726,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Lightfinder Info Bar"); if (ImGui.Checkbox("Show Lightfinder status in Server info bar", ref showLightfinderInDtr)) @@ -1827,7 +1822,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } ImGui.EndDisabled(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Alignment"); ImGui.BeginDisabled(autoAlign); @@ -1952,7 +1947,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Visibility"); var showOwn = _configService.Current.LightfinderLabelShowOwn; @@ -1990,7 +1985,7 @@ public class SettingsUi : WindowMediatorSubscriberBase } _uiShared.DrawHelpText("Toggles Lightfinder label when no nameplate(s) is visible."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Label"); var useIcon = _configService.Current.LightfinderLabelUseIcon; @@ -2096,7 +2091,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _lightfinderIconPresetIndex = -1; } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -2184,7 +2179,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Server Info Bar Colors"); @@ -2236,7 +2231,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("Nameplate Colors"); @@ -2281,7 +2276,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Spacing(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); ImGui.TextUnformatted("UI Theme"); @@ -2303,7 +2298,7 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawThemeOverridesSection(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -2401,7 +2396,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -2444,7 +2439,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Will show profiles that have the NSFW tag enabled"); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } ImGui.Separator(); @@ -2542,7 +2537,7 @@ public class SettingsUi : WindowMediatorSubscriberBase + "Default: 165 thousand"); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -2646,7 +2641,7 @@ public class SettingsUi : WindowMediatorSubscriberBase + "Default: 250 thousand"); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -2726,7 +2721,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Dummy(new Vector2(5)); - _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f); var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures; if (ImGui.Checkbox("Only downscale uncompressed textures", ref onlyUncompressed)) { @@ -2734,7 +2729,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _playerPerformanceConfigService.Save(); } _uiShared.DrawHelpText("If disabled, compressed textures will be targeted for downscaling too."); - _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 3f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f); ImGui.Dummy(new Vector2(5)); @@ -2742,7 +2737,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Dummy(new Vector2(5)); - _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); ImGui.TreePop(); } @@ -2890,7 +2885,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndPopup(); } - _uiShared.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); ImGui.TreePop(); } @@ -3468,15 +3463,13 @@ public class SettingsUi : WindowMediatorSubscriberBase private int _lastSelectedServerIndex = -1; private Task<(bool Success, bool PartialSuccess, string Result)>? _secretKeysConversionTask = null; - private CancellationTokenSource _secretKeysConversionCts = new CancellationTokenSource(); + private CancellationTokenSource _secretKeysConversionCts = new(); private async Task<(bool Success, bool partialSuccess, string Result)> ConvertSecretKeysToUIDs( ServerStorage serverStorage, CancellationToken token) { - List failedConversions = serverStorage.Authentications - .Where(u => u.SecretKeyIdx == -1 && string.IsNullOrEmpty(u.UID)).ToList(); - List conversionsToAttempt = serverStorage.Authentications - .Where(u => u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)).ToList(); + List failedConversions = [.. serverStorage.Authentications.Where(u => u.SecretKeyIdx == -1 && string.IsNullOrEmpty(u.UID))]; + List conversionsToAttempt = [.. serverStorage.Authentications.Where(u => u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID))]; List successfulConversions = []; Dictionary> secretKeyMapping = new(StringComparer.Ordinal); foreach (var authEntry in conversionsToAttempt) @@ -3546,6 +3539,7 @@ public class SettingsUi : WindowMediatorSubscriberBase sb.Append(string.Join(", ", failedConversions.Select(k => k.CharacterName))); } + _secretKeysConversionCts.Dispose(); return (true, failedConversions.Count != 0, sb.ToString()); } @@ -3914,7 +3908,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.Unindent(); } - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -3956,7 +3950,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Click anywhere on a notification to dismiss it. Notifications with action buttons (like pair requests) are excluded."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -4119,7 +4113,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Right click to reset to default (3)."); _uiShared.DrawHelpText("Width of the colored accent bar on the left side."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } } @@ -4214,7 +4208,7 @@ public class SettingsUi : WindowMediatorSubscriberBase if (ImGui.IsItemHovered()) ImGui.SetTooltip("Right click to reset to default (20)."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -4229,7 +4223,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( "Configure which sounds play for each notification type. Use the play button to preview sounds."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -4277,7 +4271,7 @@ public class SettingsUi : WindowMediatorSubscriberBase "Only show online notifications for pairs where you have set an individual note."); ImGui.Unindent(); - _uiShared.ColoredSeparator(UIColors.Get("LightlessGreen"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessGreen"), 1.5f); ImGui.TreePop(); } @@ -4293,7 +4287,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( "When you receive a pair request, show Accept/Decline buttons in the notification."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } @@ -4309,7 +4303,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText( "When a player exceeds performance thresholds or is auto-paused, show Pause/Unpause buttons in the notification."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessOrange"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessOrange"), 1.5f); ImGui.TreePop(); } @@ -4324,7 +4318,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.DrawHelpText("Disable warning notifications for missing optional plugins."); - _uiShared.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); ImGui.TreePop(); } @@ -4334,32 +4328,32 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - private NotificationLocation[] GetLightlessNotificationLocations() + private static NotificationLocation[] GetLightlessNotificationLocations() { - return new[] - { + return + [ NotificationLocation.LightlessUi, NotificationLocation.Chat, NotificationLocation.ChatAndLightlessUi, NotificationLocation.Nowhere - }; + ]; } - private NotificationLocation[] GetDownloadNotificationLocations() + private static NotificationLocation[] GetDownloadNotificationLocations() { - return new[] - { + return + [ NotificationLocation.LightlessUi, NotificationLocation.TextOverlay, NotificationLocation.Nowhere - }; + ]; } - private NotificationLocation[] GetClassicNotificationLocations() + private static NotificationLocation[] GetClassicNotificationLocations() { - return new[] - { + return + [ NotificationLocation.Toast, NotificationLocation.Chat, NotificationLocation.Both, NotificationLocation.Nowhere - }; + ]; } - private string GetNotificationLocationLabel(NotificationLocation location) + private static string GetNotificationLocationLabel(NotificationLocation location) { return location switch { @@ -4374,7 +4368,7 @@ public class SettingsUi : WindowMediatorSubscriberBase }; } - private string GetNotificationCornerLabel(NotificationCorner corner) + private static string GetNotificationCornerLabel(NotificationCorner corner) { return corner switch { diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 3347934..27da617 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -1,18 +1,20 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; -using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; -using LightlessSync.PlayerData.Pairs; -using LightlessSync.WebAPI; +using LightlessSync.Services.Profiles; using LightlessSync.UI.Services; +using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; using System.Globalization; namespace LightlessSync.UI; @@ -23,12 +25,12 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private readonly bool _isModerator = false; private readonly bool _isOwner = false; private readonly List _oneTimeInvites = []; - private readonly PairUiService _pairUiService; private readonly LightlessProfileManager _lightlessProfileManager; - private readonly FileDialogManager _fileDialogManager; private readonly UiSharedService _uiSharedService; + private readonly PairUiService _pairUiService; private List _bannedUsers = []; private LightlessGroupProfileData? _profileData = null; + private IDalamudTextureWrap? _pfpTextureWrap; private string _profileDescription = string.Empty; private int _multiInvites; private string _newPassword; @@ -38,27 +40,34 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private int _pruneDays = 14; public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, - UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager, FileDialogManager fileDialogManager) + UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) { GroupFullInfo = groupFullInfo; _apiController = apiController; _uiSharedService = uiSharedService; - _pairUiService = pairUiService; _lightlessProfileManager = lightlessProfileManager; - _fileDialogManager = fileDialogManager; - + _pairUiService = pairUiService; _isOwner = string.Equals(GroupFullInfo.OwnerUID, _apiController.UID, StringComparison.Ordinal); _isModerator = GroupFullInfo.GroupUserInfo.IsModerator(); _newPassword = string.Empty; _multiInvites = 30; _pwChangeSuccess = true; IsOpen = true; + Mediator.Subscribe(this, (msg) => + { + if (msg.GroupData == null || string.Equals(msg.GroupData.AliasOrGID, GroupFullInfo.Group.AliasOrGID, StringComparison.Ordinal)) + { + _pfpTextureWrap?.Dispose(); + _pfpTextureWrap = null; + } + }); SizeConstraints = new WindowSizeConstraints() { MinimumSize = new(700, 500), MaximumSize = new(700, 2000), }; + _pairUiService = pairUiService; } public GroupFullInfoDto GroupFullInfo { get; private set; } @@ -84,7 +93,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var perm = GroupFullInfo.GroupPermissions; using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID); - + if (tabbar) { DrawInvites(perm); @@ -92,7 +101,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase DrawManagement(); DrawPermission(perm); - + DrawProfile(); } } @@ -193,6 +202,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase ownerTab.Dispose(); } } + private void DrawProfile() { var profileTab = ImRaii.TabItem("Profile"); @@ -220,7 +230,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase ImGui.BulletText(_profileData.IsDisabled ? "Profile disabled for viewers" : "Profile active"); ImGuiHelpers.ScaledDummy(2f); - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGuiHelpers.ScaledDummy(2f); UiSharedService.TextWrapped("Open the syncshell profile editor to update images, description, tags, and visibility settings."); @@ -395,7 +405,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } } - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); } ImGui.Separator(); @@ -486,7 +496,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.TextWrapped($"Syncshell was pruned and {_pruneTask.Result} inactive user(s) have been removed."); } } - _uiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); ImGui.TreePop(); } ImGui.Separator(); @@ -532,7 +542,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } ImGui.EndTable(); } - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); ImGui.TreePop(); } ImGui.Separator(); @@ -584,8 +594,10 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } inviteTab.Dispose(); } + public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); + _pfpTextureWrap?.Dispose(); } } \ No newline at end of file diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 6a4a465..0ebfdef 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -3,6 +3,7 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; @@ -29,11 +30,15 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private readonly List _nearbySyncshells = []; private List _currentSyncshells = []; private int _selectedNearbyIndex = -1; + private int _syncshellPageIndex = 0; private readonly HashSet _recentlyJoined = new(StringComparer.Ordinal); private GroupJoinDto? _joinDto; private GroupJoinInfoDto? _joinInfo; private DefaultPermissionsDto _ownPermissions = null!; + private const bool _useTestSyncshells = false; + + private bool _compactView = false; public SyncshellFinderUI( ILogger logger, @@ -62,6 +67,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); + Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); } public override async void OnOpen() @@ -72,9 +79,21 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase protected override void DrawInternal() { - _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("PairBlue")); - _uiSharedService.ColoredSeparator(UIColors.Get("PairBlue")); + ImGui.BeginGroup(); + _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); + ImGui.SameLine(); + string checkboxLabel = "Compact view"; + float availWidth = ImGui.GetContentRegionAvail().X; + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); + + float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f; + ImGui.SetCursorPosX(rightX); + ImGui.Checkbox(checkboxLabel, ref _compactView); + ImGui.EndGroup(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); if (_nearbySyncshells.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); @@ -82,13 +101,13 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase if (!_broadcastService.IsBroadcasting) { - _uiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow")); ImGui.TextColored(UIColors.Get("LightlessYellow"), "Lightfinder is currently disabled, to locate nearby syncshells, Lightfinder must be active."); ImGuiHelpers.ScaledDummy(0.5f); ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 10.0f); - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("PairBlue")); + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessPurple")); if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) { @@ -104,106 +123,295 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } - DrawSyncshellTable(); + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); + + foreach (var shell in _nearbySyncshells) + { + string broadcasterName; + + if (_useTestSyncshells) + { + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) + ? shell.Group.Alias + : shell.Group.GID; + + broadcasterName = $"Tester of {displayName}"; + } + else + { + var broadcast = broadcasts + .FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); + + if (broadcast == null) + continue; + + var (name, address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); + if (string.IsNullOrEmpty(name)) + continue; + + var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(address); + broadcasterName = !string.IsNullOrEmpty(worldName) + ? $"{name} ({worldName})" + : name; + } + + cardData.Add((shell, broadcasterName)); + } + + if (cardData.Count == 0) + { + ImGui.TextColored(ImGuiColors.DalamudGrey, "No nearby syncshells are being broadcasted."); + return; + } + + if (_compactView) + { + DrawSyncshellGrid(cardData); + } + else + { + DrawSyncshellList(cardData); + } + if (_joinDto != null && _joinInfo != null && _joinInfo.Success) DrawConfirmation(); } - private void DrawSyncshellTable() + private void DrawSyncshellList(List<(GroupJoinDto Shell, string BroadcasterName)> listData) { - if (ImGui.BeginTable("##NearbySyncshellsTable", 3, ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg)) + const int shellsPerPage = 3; + var totalPages = (int)Math.Ceiling(listData.Count / (float)shellsPerPage); + if (totalPages <= 0) + totalPages = 1; + + _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); + + var firstIndex = _syncshellPageIndex * shellsPerPage; + var lastExclusive = Math.Min(firstIndex + shellsPerPage, listData.Count); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); + + for (int index = firstIndex; index < lastExclusive; index++) { - ImGui.TableSetupColumn("Syncshell", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Broadcaster", ImGuiTableColumnFlags.WidthStretch); - ImGui.TableSetupColumn("Join", ImGuiTableColumnFlags.WidthFixed, 80f * ImGuiHelpers.GlobalScale); - ImGui.TableHeadersRow(); + var (shell, broadcasterName) = listData[index]; - foreach (var shell in _nearbySyncshells) - { - // Check if there is an active broadcast for this syncshell, if not, skipping this syncshell - var broadcast = _broadcastScannerService.GetActiveSyncshellBroadcasts() - .FirstOrDefault(b => string.Equals(b.GID, shell.Group.GID, StringComparison.Ordinal)); + ImGui.PushID(shell.Group.GID); + float rowHeight = 90f * ImGuiHelpers.GlobalScale; - if (broadcast == null) - continue; // no active broadcasts + ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); - var (Name, Address) = _dalamudUtilService.FindPlayerByNameHash(broadcast.HashedCID); - if (string.IsNullOrEmpty(Name)) - continue; // broadcaster not found in area, skipping + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - ImGui.TableNextRow(); - ImGui.TableNextColumn(); + var style = ImGui.GetStyle(); + float startX = ImGui.GetCursorPosX(); + float regionW = ImGui.GetContentRegionAvail().X; + float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; - var displayName = !string.IsNullOrEmpty(shell.Group.Alias) ? shell.Group.Alias : shell.Group.GID; - ImGui.TextUnformatted(displayName); + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - ImGui.TableNextColumn(); - var worldName = _dalamudUtilService.GetWorldNameFromPlayerAddress(Address); - var broadcasterName = !string.IsNullOrEmpty(worldName) ? $"{Name} ({worldName})" : Name; - ImGui.TextUnformatted(broadcasterName); + float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; + ImGui.SameLine(); + ImGui.SetCursorPosX(rightX); + ImGui.TextUnformatted(broadcasterName); - ImGui.TableNextColumn(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - var label = $"Join##{shell.Group.GID}"; - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); + ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); - var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); - var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); - - if (!isAlreadyMember && !isRecentlyJoined) - { - if (ImGui.Button(label)) - { - _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); + DrawJoinButton(shell); - _ = Task.Run(async () => - { - try - { - var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( - shell.Group, - shell.Password, - shell.GroupUserPreferredPermissions - )).ConfigureAwait(false); + ImGui.EndChild(); + ImGui.PopID(); - if (info != null && info.Success) - { - _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); - _joinInfo = info; - _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + } - _logger.LogInformation($"Fetched join info for {shell.Group.GID}"); - } - else - { - _logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful"); - } - } - catch (Exception ex) - { - _logger.LogError(ex, $"Join failed for {shell.Group.GID}"); - } - }); - } - } - else - { - using (ImRaii.Disabled()) - { - ImGui.Button(label); - } - UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); - } - ImGui.PopStyleColor(3); - } + ImGui.PopStyleVar(2); - ImGui.EndTable(); + DrawPagination(totalPages); + } + + private void DrawSyncshellGrid(List<(GroupJoinDto Shell, string BroadcasterName)> cardData) + { + const int shellsPerPage = 4; + var totalPages = (int)Math.Ceiling(cardData.Count / (float)shellsPerPage); + if (totalPages <= 0) + totalPages = 1; + + _syncshellPageIndex = Math.Clamp(_syncshellPageIndex, 0, totalPages - 1); + + var firstIndex = _syncshellPageIndex * shellsPerPage; + var lastExclusive = Math.Min(firstIndex + shellsPerPage, cardData.Count); + + var avail = ImGui.GetContentRegionAvail(); + var spacing = ImGui.GetStyle().ItemSpacing; + + var cardWidth = (avail.X - spacing.X) / 2.0f; + var cardHeight = (avail.Y - spacing.Y - (ImGui.GetFrameHeightWithSpacing() * 2.0f)) / 2.0f; + cardHeight = MathF.Max(110f * ImGuiHelpers.GlobalScale, cardHeight); + + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 8.0f); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.0f); + + for (int index = firstIndex; index < lastExclusive; index++) + { + var localIndex = index - firstIndex; + var (shell, broadcasterName) = cardData[index]; + + if (localIndex % 2 != 0) + ImGui.SameLine(); + + ImGui.PushID(shell.Group.GID); + + ImGui.BeginGroup(); + _ = ImGui.BeginChild("ShellCard##" + shell.Group.GID, new Vector2(cardWidth, cardHeight), border: true); + + var displayName = !string.IsNullOrEmpty(shell.Group.Alias) + ? shell.Group.Alias + : shell.Group.GID; + + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + + ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcaster"); + ImGui.TextUnformatted(broadcasterName); + + ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); + + var buttonHeight = ImGui.GetFrameHeightWithSpacing(); + var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; + if (remainingY > 0) + ImGui.Dummy(new Vector2(0, remainingY)); + + DrawJoinButton(shell); + + ImGui.EndChild(); + ImGui.EndGroup(); + + ImGui.PopID(); + } + + ImGui.Dummy(new Vector2(0, 2 * ImGuiHelpers.GlobalScale)); + ImGui.PopStyleVar(2); + + DrawPagination(totalPages); + } + + private void DrawPagination(int totalPages) + { + if (totalPages > 1) + { + UiSharedService.ColoredSeparator(UIColors.Get("PairBlue")); + + var style = ImGui.GetStyle(); + string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}"; + + float prevWidth = ImGui.CalcTextSize("<").X + style.FramePadding.X * 2; + float nextWidth = ImGui.CalcTextSize(">").X + style.FramePadding.X * 2; + float textWidth = ImGui.CalcTextSize(pageLabel).X; + + float totalWidth = prevWidth + textWidth + nextWidth + style.ItemSpacing.X * 2; + + float availWidth = ImGui.GetContentRegionAvail().X; + float offsetX = (availWidth - totalWidth) * 0.5f; + + ImGui.SetCursorPosX(ImGui.GetCursorPosX() + offsetX); + + if (ImGui.Button("<##PrevSyncshellPage") && _syncshellPageIndex > 0) + _syncshellPageIndex--; + + ImGui.SameLine(); + ImGui.Text(pageLabel); + + ImGui.SameLine(); + if (ImGui.Button(">##NextSyncshellPage") && _syncshellPageIndex < totalPages - 1) + _syncshellPageIndex++; } } + private void DrawJoinButton(dynamic shell) + { + const string visibleLabel = "Join"; + var label = $"{visibleLabel}##{shell.Group.GID}"; + + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); + + var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); + var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); + + Vector2 buttonSize; + + if (!_compactView) + { + var style = ImGui.GetStyle(); + var textSize = ImGui.CalcTextSize(visibleLabel); + + var width = textSize.X + style.FramePadding.X * 20f; + buttonSize = new Vector2(width, 0); + + float availX = ImGui.GetContentRegionAvail().X; + float curX = ImGui.GetCursorPosX(); + float newX = curX + (availX - buttonSize.X); + ImGui.SetCursorPosX(newX); + } + else + { + buttonSize = new Vector2(-1, 0); + } + + if (!isAlreadyMember && !isRecentlyJoined) + { + if (ImGui.Button(label, buttonSize)) + { + _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); + + _ = Task.Run(async () => + { + try + { + var info = await _apiController.GroupJoinHashed(new GroupJoinHashedDto( + shell.Group, + shell.Password, + shell.GroupUserPreferredPermissions + )).ConfigureAwait(false); + + if (info != null && info.Success) + { + _joinDto = new GroupJoinDto(shell.Group, shell.Password, shell.GroupUserPreferredPermissions); + _joinInfo = info; + _ownPermissions = _apiController.DefaultPermissions.DeepClone()!; + + _logger.LogInformation($"Fetched join info for {shell.Group.GID}"); + } + else + { + _logger.LogWarning($"Failed to join {shell.Group.GID}: info was null or unsuccessful"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Join failed for {shell.Group.GID}"); + } + }); + } + } + else + { + using (ImRaii.Disabled()) + { + ImGui.Button(label, buttonSize); + } + + UiSharedService.AttachToolTip("Already a member or owner of this Syncshell."); + } + + ImGui.PopStyleColor(3); + } private void DrawConfirmation() { if (_joinDto != null && _joinInfo != null) @@ -263,53 +471,97 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGui.NewLine(); } - private async Task RefreshSyncshellsAsync() + private async Task RefreshSyncshellsAsync(string? gid = null) { var syncshellBroadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); var snapshot = _pairUiService.GetSnapshot(); - _currentSyncshells = snapshot.GroupPairs.Keys.ToList(); - - _recentlyJoined.RemoveWhere(gid => _currentSyncshells.Any(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); + _currentSyncshells = [.. snapshot.GroupPairs.Keys]; - if (syncshellBroadcasts.Count == 0) + _recentlyJoined.RemoveWhere(gid => + _currentSyncshells.Exists(s => string.Equals(s.GID, gid, StringComparison.Ordinal))); + + List? updatedList = []; + + if (_useTestSyncshells) + { + updatedList = BuildTestSyncshells(); + } + else + { + if (syncshellBroadcasts.Count == 0) + { + ClearSyncshells(); + return; + } + + try + { + var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts) + .ConfigureAwait(false); + updatedList = groups?.ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); + return; + } + } + + if (updatedList == null || updatedList.Count == 0) { ClearSyncshells(); return; } - List? updatedList = []; - try + if (gid != null && _recentlyJoined.Contains(gid)) { - var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts).ConfigureAwait(false); - updatedList = groups?.ToList(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to refresh broadcasted syncshells."); - return; + _recentlyJoined.Clear(); } - if (updatedList != null) + var previousGid = GetSelectedGid(); + + _nearbySyncshells.Clear(); + _nearbySyncshells.AddRange(updatedList); + + if (previousGid != null) { - var previousGid = GetSelectedGid(); + var newIndex = _nearbySyncshells.FindIndex(s => + string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - _nearbySyncshells.Clear(); - _nearbySyncshells.AddRange(updatedList); - - if (previousGid != null) + if (newIndex >= 0) { - var newIndex = _nearbySyncshells.FindIndex(s => string.Equals(s.Group.GID, previousGid, StringComparison.Ordinal)); - if (newIndex >= 0) - { - _selectedNearbyIndex = newIndex; - return; - } + _selectedNearbyIndex = newIndex; + return; } } ClearSelection(); } + private List BuildTestSyncshells() + { + var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell"); + var testGroup2 = new GroupData("TEST-BETA", "Beta Shell"); + var testGroup3 = new GroupData("TEST-GAMMA", "Gamma Shell"); + var testGroup4 = new GroupData("TEST-DELTA", "Delta Shell"); + var testGroup5 = new GroupData("TEST-CHARLIE", "Charlie Shell"); + var testGroup6 = new GroupData("TEST-OMEGA", "Omega Shell"); + var testGroup7 = new GroupData("TEST-POINT", "Point Shell"); + var testGroup8 = new GroupData("TEST-HOTEL", "Hotel Shell"); + + return + [ + new(testGroup1, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup2, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup3, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup4, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup5, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup6, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup7, "", GroupUserPreferredPermissions.NoneSet), + new(testGroup8, "", GroupUserPreferredPermissions.NoneSet), + ]; + } + private void ClearSyncshells() { if (_nearbySyncshells.Count == 0) @@ -322,6 +574,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private void ClearSelection() { _selectedNearbyIndex = -1; + _syncshellPageIndex = 0; _joinDto = null; _joinInfo = null; } diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index cd24118..8562595 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -123,133 +123,133 @@ public class TopTabMenu } UiSharedService.AttachToolTip("Individual Pair Menu"); - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - var x = ImGui.GetCursorScreenPos(); - if (ImGui.Button(FontAwesomeIcon.Users.ToIconString(), buttonSize)) + using (ImRaii.PushFont(UiBuilder.IconFont)) { - TabSelection = TabSelection == SelectedTab.Syncshell ? SelectedTab.None : SelectedTab.Syncshell; + var x = ImGui.GetCursorScreenPos(); + if (ImGui.Button(FontAwesomeIcon.Users.ToIconString(), buttonSize)) + { + TabSelection = TabSelection == SelectedTab.Syncshell ? SelectedTab.None : SelectedTab.Syncshell; + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + ImGui.SameLine(); + var xAfter = ImGui.GetCursorScreenPos(); + if (TabSelection == SelectedTab.Syncshell) + drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, + xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, + underlineColor, 2); } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + UiSharedService.AttachToolTip("Syncshell Menu"); + + using (ImRaii.PushFont(UiBuilder.IconFont)) { - Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + if (ImGui.Button(FontAwesomeIcon.Comments.ToIconString(), buttonSize)) + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(ZoneChatUi))); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } } + UiSharedService.AttachToolTip("Zone Chat"); ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Syncshell) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); - } - UiSharedService.AttachToolTip("Syncshell Menu"); - - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - if (ImGui.Button(FontAwesomeIcon.Comments.ToIconString(), buttonSize)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(ZoneChatUi))); - } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); - } - } - UiSharedService.AttachToolTip("Zone Chat"); - ImGui.SameLine(); - - ImGui.SameLine(); - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - var x = ImGui.GetCursorScreenPos(); - if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) - { - TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; - } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); - } ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.Lightfinder) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); - } - UiSharedService.AttachToolTip("Lightfinder"); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + var x = ImGui.GetCursorScreenPos(); + if (ImGui.Button(FontAwesomeIcon.Compass.ToIconString(), buttonSize)) + { + TabSelection = TabSelection == SelectedTab.Lightfinder ? SelectedTab.None : SelectedTab.Lightfinder; + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } - ImGui.SameLine(); - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - var x = ImGui.GetCursorScreenPos(); - if (ImGui.Button(FontAwesomeIcon.UserCog.ToIconString(), buttonSize)) - { - TabSelection = TabSelection == SelectedTab.UserConfig ? SelectedTab.None : SelectedTab.UserConfig; - } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + ImGui.SameLine(); + var xAfter = ImGui.GetCursorScreenPos(); + if (TabSelection == SelectedTab.Lightfinder) + drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, + xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, + underlineColor, 2); } + UiSharedService.AttachToolTip("Lightfinder"); ImGui.SameLine(); - var xAfter = ImGui.GetCursorScreenPos(); - if (TabSelection == SelectedTab.UserConfig) - drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, - xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, - underlineColor, 2); - } - UiSharedService.AttachToolTip("Your User Menu"); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + var x = ImGui.GetCursorScreenPos(); + if (ImGui.Button(FontAwesomeIcon.UserCog.ToIconString(), buttonSize)) + { + TabSelection = TabSelection == SelectedTab.UserConfig ? SelectedTab.None : SelectedTab.UserConfig; + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } - ImGui.SameLine(); - using (ImRaii.PushFont(UiBuilder.IconFont)) - { - var x = ImGui.GetCursorScreenPos(); - if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString(), buttonSize)) - { - _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); - } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) - { - Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + ImGui.SameLine(); + var xAfter = ImGui.GetCursorScreenPos(); + if (TabSelection == SelectedTab.UserConfig) + drawList.AddLine(x with { Y = x.Y + buttonSize.Y + spacing.Y }, + xAfter with { Y = xAfter.Y + buttonSize.Y + spacing.Y, X = xAfter.X - spacing.X }, + underlineColor, 2); } + UiSharedService.AttachToolTip("Your User Menu"); + ImGui.SameLine(); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + var x = ImGui.GetCursorScreenPos(); + if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString(), buttonSize)) + { + _lightlessMediator.Publish(new UiToggleMessage(typeof(SettingsUi))); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + ImGui.SameLine(); + } + UiSharedService.AttachToolTip("Open Lightless Settings"); + + ImGui.NewLine(); + btncolor.Dispose(); + + ImGuiHelpers.ScaledDummy(spacing); + + if (TabSelection == SelectedTab.Individual) + { + DrawAddPair(availableWidth, spacing.X); + DrawGlobalIndividualButtons(availableWidth, spacing.X); + } + else if (TabSelection == SelectedTab.Syncshell) + { + DrawSyncshellMenu(availableWidth, spacing.X); + DrawGlobalSyncshellButtons(availableWidth, spacing.X); + } + else if (TabSelection == SelectedTab.Lightfinder) + { + DrawLightfinderMenu(availableWidth, spacing.X); + } + else if (TabSelection == SelectedTab.UserConfig) + { + DrawUserConfig(availableWidth, spacing.X); + } + + if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); + + + DrawIncomingPairRequests(availableWidth); + + ImGui.Separator(); + + DrawFilter(availableWidth, spacing.X); } - UiSharedService.AttachToolTip("Open Lightless Settings"); - - ImGui.NewLine(); - btncolor.Dispose(); - - ImGuiHelpers.ScaledDummy(spacing); - - if (TabSelection == SelectedTab.Individual) - { - DrawAddPair(availableWidth, spacing.X); - DrawGlobalIndividualButtons(availableWidth, spacing.X); - } - else if (TabSelection == SelectedTab.Syncshell) - { - DrawSyncshellMenu(availableWidth, spacing.X); - DrawGlobalSyncshellButtons(availableWidth, spacing.X); - } - else if (TabSelection == SelectedTab.Lightfinder) - { - DrawLightfinderMenu(availableWidth, spacing.X); - } - else if (TabSelection == SelectedTab.UserConfig) - { - DrawUserConfig(availableWidth, spacing.X); - } - - if (TabSelection != SelectedTab.None) ImGuiHelpers.ScaledDummy(3f); - - - DrawIncomingPairRequests(availableWidth); - - ImGui.Separator(); - - DrawFilter(availableWidth, spacing.X); - } finally { _currentSnapshot = null; diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 98551f3..90911d7 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -45,7 +45,7 @@ namespace LightlessSync.UI return HexToRgba(customColorHex); if (!DefaultHexColors.TryGetValue(name, out var hex)) - throw new ArgumentException($"Color '{name}' not found in UIColors."); + throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); } @@ -53,7 +53,7 @@ namespace LightlessSync.UI public static void Set(string name, Vector4 color) { if (!DefaultHexColors.ContainsKey(name)) - throw new ArgumentException($"Color '{name}' not found in UIColors."); + throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); if (_configService != null) { @@ -83,7 +83,7 @@ namespace LightlessSync.UI public static Vector4 GetDefault(string name) { if (!DefaultHexColors.TryGetValue(name, out var hex)) - throw new ArgumentException($"Color '{name}' not found in UIColors."); + throw new ArgumentException($"Color '{name}' not found in UIColors.", nameof(name)); return HexToRgba(hex); } @@ -101,10 +101,10 @@ namespace LightlessSync.UI public static Vector4 HexToRgba(string hexColor) { hexColor = hexColor.TrimStart('#'); - int r = int.Parse(hexColor.Substring(0, 2), NumberStyles.HexNumber); - int g = int.Parse(hexColor.Substring(2, 2), NumberStyles.HexNumber); - int b = int.Parse(hexColor.Substring(4, 2), NumberStyles.HexNumber); - int a = hexColor.Length == 8 ? int.Parse(hexColor.Substring(6, 2), NumberStyles.HexNumber) : 255; + int r = int.Parse(hexColor[..2], NumberStyles.HexNumber); + int g = int.Parse(hexColor[2..4], NumberStyles.HexNumber); + int b = int.Parse(hexColor[4..6], NumberStyles.HexNumber); + int a = hexColor.Length == 8 ? int.Parse(hexColor[6..8], NumberStyles.HexNumber) : 255; return new Vector4(r / 255f, g / 255f, b / 255f, a / 255f); } diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 95132ec..b3734a3 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -71,7 +71,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase private bool _isOneDrive = false; private bool _isPenumbraDirectory = false; private bool _moodlesExists = false; - private Dictionary _oauthTokenExpiry = new(); + private readonly Dictionary _oauthTokenExpiry = []; private bool _penumbraExists = false; private bool _petNamesExists = false; private int _serverSelectionIndex = -1; @@ -487,7 +487,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ); } - public void ColoredSeparator(Vector4? color = null, float thickness = 1f, float indent = 0f) + public static void ColoredSeparator(Vector4? color = null, float thickness = 1f, float indent = 0f) { var drawList = ImGui.GetWindowDrawList(); var min = ImGui.GetCursorScreenPos(); @@ -1080,7 +1080,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase { using (ImRaii.Disabled(_discordOAuthUIDs == null)) { - var aliasPairs = _discordOAuthUIDs?.Result?.Select(t => new UIDAliasPair(t.Key, t.Value)).ToList() ?? [new UIDAliasPair(item.UID ?? null, null)]; + var aliasPairs = _discordOAuthUIDs?.Result?.Select(t => new UidAliasPair(t.Key, t.Value)).ToList() ?? [new UidAliasPair(item.UID ?? null, null)]; var uidComboName = "UID###" + item.CharacterName + item.WorldId + serverUri + indexOffset + aliasPairs.Count; DrawCombo(uidComboName, aliasPairs, (v) => @@ -1360,6 +1360,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase UidFont.Dispose(); GameFont.Dispose(); MediumFont.Dispose(); + _discordOAuthGetCts.Dispose(); } private static void CenterWindow(float width, float height, ImGuiCond cond = ImGuiCond.None) @@ -1443,6 +1444,7 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return result; } + public sealed record IconScaleData(Vector2 IconSize, Vector2 NormalizedIconScale, float OffsetX, float IconScaling); - private record UIDAliasPair(string? UID, string? Alias); + private sealed record UidAliasPair(string? UID, string? Alias); } \ No newline at end of file diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index 02e0b4d..5fb2480 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -25,7 +25,6 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase private ChangelogFile _changelog = new(); private CreditsFile _credits = new(); private bool _scrollToTop; - private int _selectedTab; private bool _hasInitializedCollapsingHeaders; private struct Particle @@ -160,7 +159,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase DrawParticleEffects(headerStart, extendedParticleSize); } - private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) + private static void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) { var drawList = ImGui.GetWindowDrawList(); @@ -188,7 +187,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase } } - private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) + private static void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) { var drawList = ImGui.GetWindowDrawList(); var gradientHeight = 60f; @@ -513,7 +512,6 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase { if (changelogTab) { - _selectedTab = 0; DrawChangelog(); } } @@ -524,7 +522,6 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase { if (creditsTab) { - _selectedTab = 1; DrawCredits(); } } @@ -558,7 +555,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase } } - private void DrawCreditCategory(CreditCategory category) + private static void DrawCreditCategory(CreditCategory category) { DrawFeatureSection(category.Category, UIColors.Get("LightlessBlue")); @@ -745,7 +742,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase using var changelogStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.changelog.yaml"); if (changelogStream != null) { - using var reader = new StreamReader(changelogStream, Encoding.UTF8, true, 128); + using var reader = new StreamReader(changelogStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, 128); var yaml = reader.ReadToEnd(); _changelog = deserializer.Deserialize(yaml) ?? new(); } @@ -754,7 +751,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase using var creditsStream = assembly.GetManifestResourceStream("LightlessSync.Changelog.credits.yaml"); if (creditsStream != null) { - using var reader = new StreamReader(creditsStream, Encoding.UTF8, true, 128); + using var reader = new StreamReader(creditsStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, 128); var yaml = reader.ReadToEnd(); _credits = deserializer.Deserialize(yaml) ?? new(); } diff --git a/LightlessSync/Utils/Crypto.cs b/LightlessSync/Utils/Crypto.cs index f4d2469..09ed636 100644 --- a/LightlessSync/Utils/Crypto.cs +++ b/LightlessSync/Utils/Crypto.cs @@ -1,3 +1,4 @@ +using Blake3; using System; using System.Collections.Concurrent; using System.IO; @@ -16,14 +17,88 @@ public static class Crypto private static readonly ConcurrentDictionary _hashListSHA256 = new(StringComparer.Ordinal); private static readonly SHA256CryptoServiceProvider _sha256CryptoProvider = new(); - public static string GetFileHash(this string filePath) + // BLAKE3 hash caches + private static readonly Dictionary<(string, ushort), string> _hashListPlayersBlake3 = []; + private static readonly Dictionary _hashListBlake3 = new(StringComparer.Ordinal); + + /// + /// Supports Blake3 or SHA1 for file transfers, no SHA256 supported on it + /// + public enum HashAlgo { - using SHA1 sha1 = SHA1.Create(); - using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); - return BitConverter.ToString(sha1.ComputeHash(stream)).Replace("-", "", StringComparison.Ordinal); + Blake3, + Sha1 } - public static async Task GetFileHashAsync(string filePath, CancellationToken cancellationToken = default) + /// + /// Detects which algo is being used for the file + /// + /// Hashed string + /// HashAlgo + public static HashAlgo DetectAlgo(string hashHex) + { + if (hashHex.Length == 40) + return HashAlgo.Sha1; + + return HashAlgo.Blake3; + } + + #region File Hashing + + /// + /// Compute file hash with given algorithm, supports BLAKE3 and Sha1 for file hashing + /// + /// Filepath for the hashing + /// BLAKE3 or Sha1 + /// Hashed file hash + /// Not a valid HashAlgo or Filepath + public static string ComputeFileHash(string filePath, HashAlgo algo) + { + return algo switch + { + HashAlgo.Blake3 => ComputeFileHashBlake3(filePath), + HashAlgo.Sha1 => ComputeFileHashSha1(filePath), + _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, null) + }; + } + + /// + /// Compute file hash asynchronously with given algorithm, supports BLAKE3 and SHA1 for file hashing + /// + /// Filepath for the hashing + /// BLAKE3 or Sha1 + /// Hashed file hash + /// Not a valid HashAlgo or Filepath + public static async Task ComputeFileHashAsync(string filePath, HashAlgo algo, CancellationToken cancellationToken = default) + { + return algo switch + { + HashAlgo.Blake3 => await ComputeFileHashBlake3Async(filePath, cancellationToken).ConfigureAwait(false), + HashAlgo.Sha1 => await ComputeFileHashSha1Async(filePath, cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException(nameof(algo), algo, message: null) + }; + } + + /// + /// Computes an file hash with SHA1 + /// + /// Filepath that has to be computed + /// Hashed file in hex string + private static string ComputeFileHashSha1(string filePath) + { + using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var sha1 = SHA1.Create(); + var hash = sha1.ComputeHash(stream); + return Convert.ToHexString(hash); + } + + /// + /// Computes an file hash with SHA1 asynchronously + /// + /// Filepath that has to be computed + /// Cancellation token + /// Hashed file in hex string hashed in SHA1 + private static async Task ComputeFileHashSha1Async(string filePath, CancellationToken cancellationToken) { var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous); await using (stream.ConfigureAwait(false)) @@ -32,22 +107,121 @@ public static class Crypto var buffer = new byte[8192]; int bytesRead; - while ((bytesRead = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) { sha1.TransformBlock(buffer, 0, bytesRead, outputBuffer: null, 0); } sha1.TransformFinalBlock([], 0, 0); - return Convert.ToHexString(sha1.Hash!); } } - public static string GetHash256(this (string, ushort) playerToHash) + /// + /// Computes an file hash with Blake3 + /// + /// Filepath that has to be computed + /// Hashed file in hex string hashed in Blake3 + private static string ComputeFileHashBlake3(string filePath) { - return _hashListPlayersSHA256.GetOrAdd(playerToHash, key => ComputeHashSHA256(key.Item1 + key.Item2.ToString())); + using var stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + using var hasher = Hasher.New(); + + var buffer = new byte[_bufferSize]; + int bytesRead; + while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) + { + hasher.Update(buffer.AsSpan(0, bytesRead)); + } + + var hash = hasher.Finalize(); + return hash.ToString(); } + + /// + /// Computes an file hash with Blake3 asynchronously + /// + /// Filepath that has to be computed + /// Hashed file in hex string hashed in Blake3 + private static async Task ComputeFileHashBlake3Async(string filePath, CancellationToken cancellationToken) + { + var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete, bufferSize: _bufferSize, options: FileOptions.Asynchronous); + await using (stream.ConfigureAwait(false)) + { + using var hasher = Hasher.New(); + + var buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + hasher.Update(buffer.AsSpan(0, bytesRead)); + } + + var hash = hasher.Finalize(); + return hash.ToString(); + } + } + #endregion + + + #region String hashing + + public static string GetBlake3Hash(this (string, ushort) playerToHash) + { + if (_hashListPlayersBlake3.TryGetValue(playerToHash, out var hash)) + return hash; + + var toHash = playerToHash.Item1 + playerToHash.Item2.ToString(); + + hash = ComputeBlake3Hex(toHash); + _hashListPlayersBlake3[playerToHash] = hash; + return hash; + } + + /// + /// Computes or gets an Blake3 hash(ed) string. + /// + /// String that needs to be hashsed + /// Hashed string + public static string GetBlake3Hash(this string stringToHash) + { + return GetOrComputeBlake3(stringToHash); + } + + private static string GetOrComputeBlake3(string stringToCompute) + { + if (_hashListBlake3.TryGetValue(stringToCompute, out var hash)) + return hash; + + hash = ComputeBlake3Hex(stringToCompute); + _hashListBlake3[stringToCompute] = hash; + return hash; + } + + private static string ComputeBlake3Hex(string input) + { + var bytes = Encoding.UTF8.GetBytes(input); + + var hash = Hasher.Hash(bytes); + + return Convert.ToHexString(hash.AsSpan()); + } + + public static string GetHash256(this (string, ushort) playerToHash) + { + if (_hashListPlayersSHA256.TryGetValue(playerToHash, out var hash)) + return hash; + + return _hashListPlayersSHA256[playerToHash] = + Convert.ToHexString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(playerToHash.Item1 + playerToHash.Item2.ToString()))); + } + + /// + /// Computes or gets an SHA256 hash(ed) string. + /// + /// String that needs to be hashsed + /// Hashed string public static string GetHash256(this string stringToHash) { return _hashListSHA256.GetOrAdd(stringToHash, ComputeHashSHA256); @@ -55,8 +229,13 @@ public static class Crypto private static string ComputeHashSHA256(string stringToCompute) { - using var sha = SHA256.Create(); - return BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))).Replace("-", "", StringComparison.Ordinal); - } + if (_hashListSHA256.TryGetValue(stringToCompute, out var hash)) + return hash; + + return _hashListSHA256[stringToCompute] = + Convert.ToHexString(_sha256CryptoProvider.ComputeHash(Encoding.UTF8.GetBytes(stringToCompute))); + } + + #endregion #pragma warning restore SYSLIB0021 // Type or member is obsolete } \ No newline at end of file diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessSync/Utils/FileSystemHelper.cs index f7b3c45..b27fb1c 100644 --- a/LightlessSync/Utils/FileSystemHelper.cs +++ b/LightlessSync/Utils/FileSystemHelper.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Logging; using System.Collections.Concurrent; -using System.Diagnostics; using System.Runtime.InteropServices; namespace LightlessSync.Utils @@ -157,7 +156,7 @@ namespace LightlessSync.Utils return mountOptions; } - catch (Exception ex) + catch (Exception) { return string.Empty; } @@ -234,40 +233,6 @@ namespace LightlessSync.Utils return clusterSize; } - string realPath = fi.FullName; - if (isWine && realPath.StartsWith("Z:\\", StringComparison.OrdinalIgnoreCase)) - { - realPath = "/" + realPath.Substring(3).Replace('\\', '/'); - } - - var psi = new ProcessStartInfo - { - FileName = "/bin/bash", - Arguments = $"-c \"stat -f -c %s '{realPath.Replace("'", "'\\''")}'\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = "/" - }; - - using var proc = Process.Start(psi); - - string stdout = proc?.StandardOutput.ReadToEnd().Trim() ?? ""; - string _stderr = proc?.StandardError.ReadToEnd() ?? ""; - - try { proc?.WaitForExit(); } - catch (Exception ex) { logger?.LogTrace(ex, "stat WaitForExit failed under Wine; ignoring"); } - - if (!(!int.TryParse(stdout, out int block) || block <= 0)) - { - _blockSizeCache[root] = block; - logger?.LogTrace("Filesystem block size via stat for {root}: {block}", root, block); - return block; - } - - logger?.LogTrace("stat did not return valid block size for {file}, output: {out}", fi.FullName, stdout); - _blockSizeCache[root] = _defaultBlockSize; return _defaultBlockSize; } catch (Exception ex) diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 89ad891..c8b9a7b 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -497,10 +497,9 @@ public static class SeStringUtils continue; var hasColor = fragment.Color.HasValue; - Vector4 color = default; if (hasColor) { - color = fragment.Color!.Value; + Vector4 color = fragment.Color!.Value; builder.PushColorRgba(color); } @@ -673,7 +672,7 @@ public static class SeStringUtils protected abstract byte ChunkType { get; } } - private class ColorPayload : AbstractColorPayload + private sealed class ColorPayload : AbstractColorPayload { protected override byte ChunkType => 0x13; @@ -687,12 +686,12 @@ public static class SeStringUtils public ColorPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } } - private class ColorEndPayload : AbstractColorEndPayload + private sealed class ColorEndPayload : AbstractColorEndPayload { protected override byte ChunkType => 0x13; } - private class GlowPayload : AbstractColorPayload + private sealed class GlowPayload : AbstractColorPayload { protected override byte ChunkType => 0x14; @@ -706,7 +705,7 @@ public static class SeStringUtils public GlowPayload(Vector4 color) : this(new Vector3(color.X, color.Y, color.Z)) { } } - private class GlowEndPayload : AbstractColorEndPayload + private sealed class GlowEndPayload : AbstractColorEndPayload { protected override byte ChunkType => 0x14; } diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 0ce7890..94df4fa 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -319,8 +319,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase bytesRead = await readTask.ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException ex) { + Logger.LogWarning(ex, "Request got cancelled : {url}", requestUrl); throw; } diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index ff3e896..f017bb1 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -190,7 +190,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Logger.LogInformation("Not recreating Connection, paused"); _connectionDto = null; await StopConnectionAsync(ServerState.Disconnected).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } @@ -204,7 +207,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Mediator.Publish(new NotificationMessage("Multiple Identical Characters detected", "Your Service configuration has multiple characters with the same name and world set up. Delete the duplicates in the character management to be able to connect to Lightless.", NotificationType.Error)); await StopConnectionAsync(ServerState.MultiChara).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } @@ -213,7 +219,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Logger.LogWarning("No secret key set for current character"); _connectionDto = null; await StopConnectionAsync(ServerState.NoSecretKey).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } } @@ -227,7 +236,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Mediator.Publish(new NotificationMessage("Multiple Identical Characters detected", "Your Service configuration has multiple characters with the same name and world set up. Delete the duplicates in the character management to be able to connect to Lightless.", NotificationType.Error)); await StopConnectionAsync(ServerState.MultiChara).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } @@ -236,7 +248,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Logger.LogWarning("No UID/OAuth set for current character"); _connectionDto = null; await StopConnectionAsync(ServerState.OAuthMisconfigured).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } @@ -245,7 +260,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Logger.LogWarning("OAuth2 login token could not be updated"); _connectionDto = null; await StopConnectionAsync(ServerState.OAuthLoginTokenStale).ConfigureAwait(false); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } return; } } @@ -256,7 +274,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(ApiController), Services.Events.EventSeverity.Informational, $"Starting Connection to {_serverManager.CurrentServer.ServerName}"))); - _connectionCancellationTokenSource?.Cancel(); + if (_connectionCancellationTokenSource != null) + { + await _connectionCancellationTokenSource.CancelAsync().ConfigureAwait(false); + } _connectionCancellationTokenSource?.Dispose(); _connectionCancellationTokenSource = new CancellationTokenSource(); var token = _connectionCancellationTokenSource.Token; @@ -730,7 +751,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL $"Stopping existing connection to {_serverManager.CurrentServer.ServerName}"))); _initialized = false; - _healthCheckTokenSource?.Cancel(); + if (_healthCheckTokenSource != null) + { + await _healthCheckTokenSource.CancelAsync().ConfigureAwait(false); + } Mediator.Publish(new DisconnectedMessage()); _lightlessHub = null; _connectionDto = null; diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index e1b339e..a109393 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net9.0-windows7.0": { + "Blake3": { + "type": "Direct", + "requested": "[2.0.0, )", + "resolved": "2.0.0", + "contentHash": "v447kojeuNYSY5dvtVGG2bv1+M3vOWJXcrYWwXho/2uUpuwK6qPeu5WSMlqLm4VRJu96kysVO11La0zN3dLAuQ==" + }, "DalamudPackager": { "type": "Direct", "requested": "[13.1.0, )", -- 2.49.1 From 1e88fe0cf31553d8fa579e5eabf0071d032ccf5c Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 29 Nov 2025 22:42:55 +0100 Subject: [PATCH 056/140] Fixed context menu items, made static function for it to be used --- LightlessSync/PlayerData/Pairs/Pair.cs | 49 ++++------- LightlessSync/Services/ContextMenuService.cs | 85 +++++++++++++------- LightlessSync/UI/UISharedService.cs | 19 ++++- 3 files changed, 90 insertions(+), 63 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index a861dae..0eda06a 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -6,8 +6,9 @@ using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.User; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; -using Microsoft.Extensions.Logging; +using LightlessSync.UI; using LightlessSync.WebAPI; +using Microsoft.Extensions.Logging; namespace LightlessSync.PlayerData.Pairs; @@ -22,6 +23,8 @@ public class Pair private readonly ServerConfigurationManager _serverConfigurationManager; private readonly Lazy _apiController; + private const int _lightlessPrefixColor = 708; + public Pair( ILogger logger, UserFullPairDto userPair, @@ -89,48 +92,28 @@ public class Pair return; } - var openProfileSeString = new SeStringBuilder().AddText("Open Profile").Build(); - var reapplyDataSeString = new SeStringBuilder().AddText("Reapply last data").Build(); - var cyclePauseState = new SeStringBuilder().AddText("Cycle pause state").Build(); - var changePermissions = new SeStringBuilder().AddText("Change Permissions").Build(); - - args.AddMenuItem(new MenuItem + UiSharedService.AddContextMenuItem(args, name: "Open Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { - Name = openProfileSeString, - OnClicked = _ => _mediator.Publish(new ProfileOpenStandaloneMessage(this)), - UseDefaultPrefix = false, - PrefixChar = 'L', - PrefixColor = 708 + _mediator.Publish(new ProfileOpenStandaloneMessage(this)); + return Task.CompletedTask; }); - args.AddMenuItem(new MenuItem + UiSharedService.AddContextMenuItem(args, name: "Reapply last data", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { - Name = reapplyDataSeString, - OnClicked = _ => ApplyLastReceivedData(forced: true), - UseDefaultPrefix = false, - PrefixChar = 'L', - PrefixColor = 708 + ApplyLastReceivedData(forced: true); + return Task.CompletedTask; }); - args.AddMenuItem(new MenuItem + UiSharedService.AddContextMenuItem(args, name: "Change Permissions", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { - Name = changePermissions, - OnClicked = _ => _mediator.Publish(new OpenPermissionWindow(this)), - UseDefaultPrefix = false, - PrefixChar = 'L', - PrefixColor = 708 + _mediator.Publish(new OpenPermissionWindow(this)); + return Task.CompletedTask; }); - args.AddMenuItem(new MenuItem + UiSharedService.AddContextMenuItem(args, name: "Cycle pause state", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => { - Name = cyclePauseState, - OnClicked = _ => - { - TriggerCyclePause(); - }, - UseDefaultPrefix = false, - PrefixChar = 'L', - PrefixColor = 708 + TriggerCyclePause(); + return Task.CompletedTask; }); } diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 42cac86..78e34a8 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -1,5 +1,4 @@ -using LightlessSync; -using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.Gui.ContextMenu; using Dalamud.Plugin; using Dalamud.Plugin.Services; @@ -12,6 +11,7 @@ using Lumina.Excel.Sheets; using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using LightlessSync.UI; namespace LightlessSync.Services; @@ -33,6 +33,8 @@ internal class ContextMenuService : IHostedService private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessMediator _mediator; + private const int _lightlessPrefixColor = 708; + public ContextMenuService( IContextMenu contextMenu, IDalamudPluginInterface pluginInterface, @@ -40,7 +42,7 @@ internal class ContextMenuService : IHostedService ILogger logger, DalamudUtilService dalamudUtil, ApiController apiController, - IObjectTable objectTable, + IObjectTable objectTable, LightlessConfigService configService, PairRequestService pairRequestService, PairUiService pairUiService, @@ -97,44 +99,74 @@ internal class ContextMenuService : IHostedService return; if (args.AddonName != null) + { + var addonName = args.AddonName; + _logger.LogTrace("Context menu addon name: {AddonName}", addonName); return; + } if (args.Target is not MenuTargetDefault target) + { + _logger.LogTrace("Context menu target is not MenuTargetDefault."); return; + } + + _logger.LogTrace("Context menu opened for target: {Target}", target.TargetName ?? "null"); if (string.IsNullOrEmpty(target.TargetName) || target.TargetObjectId == 0 || target.TargetHomeWorld.RowId == 0) + { + _logger.LogTrace("Context menu target has invalid data: Name='{TargetName}', ObjectId={TargetObjectId}, HomeWorldId={TargetHomeWorldId}", target.TargetName, target.TargetObjectId, target.TargetHomeWorld.RowId); return; + } IPlayerCharacter? targetData = GetPlayerFromObjectTable(target); - if (targetData == null || targetData.Address == nint.Zero || _clientState.LocalPlayer == null) - return; - - //Check if user is directly paired or is own. - if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _clientState.LocalPlayer.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus) + if (targetData == null || targetData.Address == nint.Zero || _objectTable.LocalPlayer == null) + { + _logger.LogTrace("Target player {TargetName}@{World} not found in object table.", target.TargetName, target.TargetHomeWorld.RowId); return; + } var snapshot = _pairUiService.GetSnapshot(); var pair = snapshot.PairsByUid.Values.FirstOrDefault(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue && - (ulong)p.PlayerCharacterId == target.TargetObjectId); + p.PlayerCharacterId == target.TargetObjectId); if (pair is not null) { + _logger.LogTrace("Target player {TargetName}@{World} is already paired, adding existing pair context menu.", target.TargetName, target.TargetHomeWorld.RowId); + pair.AddContextMenu(args); + if (!pair.IsDirectlyPaired) + { + _logger.LogTrace("Target player {TargetName}@{World} is not directly paired, add direct pair menu item", target.TargetName, target.TargetHomeWorld.RowId); + AddDirectPairMenuItem(args); + } + return; } + _logger.LogTrace("Target player {TargetName}@{World} is not paired, adding direct pair request context menu.", target.TargetName, target.TargetHomeWorld.RowId); + //Check if user is directly paired or is own. - if (VisibleUserIds.Contains(target.TargetObjectId) || (_clientState.LocalPlayer?.GameObjectId ?? 0) == target.TargetObjectId) + if (VisibleUserIds.Any(u => u == target.TargetObjectId) || _objectTable.LocalPlayer?.GameObjectId == target.TargetObjectId || !_configService.Current.EnableRightClickMenus) + { + _logger.LogTrace("Target player {TargetName}@{World} is already paired or is self, or right-click menus are disabled.", target.TargetName, target.TargetHomeWorld.RowId); return; + } if (_clientState.IsPvPExcludingDen || _clientState.IsGPosing) + { + _logger.LogTrace("Cannot send pair request to {TargetName}@{World} while in PvP or GPose.", target.TargetName, target.TargetHomeWorld.RowId); return; + } var world = GetWorld(target.TargetHomeWorld.RowId); if (!IsWorldValid(world)) + { + _logger.LogTrace("Target player {TargetName}@{World} is on an invalid world.", target.TargetName, target.TargetHomeWorld.RowId); return; + } string? targetHashedCid = null; if (_broadcastService.IsBroadcasting) @@ -145,31 +177,26 @@ internal class ContextMenuService : IHostedService if (!string.IsNullOrEmpty(targetHashedCid) && CanOpenLightfinderProfile(targetHashedCid)) { var hashedCid = targetHashedCid; - args.AddMenuItem(new MenuItem - { - Name = "Open Lightless Profile", - PrefixChar = 'L', - UseDefaultPrefix = false, - PrefixColor = 708, - OnClicked = async _ => await HandleLightfinderProfileSelection(hashedCid!).ConfigureAwait(false) - }); + UiSharedService.AddContextMenuItem(args, name: "Open Lightless Profile", prefixChar: 'L', colorMenuItem: _lightlessPrefixColor, onClick: () => HandleLightfinderProfileSelection(hashedCid)); } - args.AddMenuItem(new MenuItem - { - Name = "Send Direct Pair Request", - PrefixChar = 'L', - UseDefaultPrefix = false, - PrefixColor = 708, - OnClicked = _ => HandleSelection(args).ConfigureAwait(false).GetAwaiter().GetResult() - }); + AddDirectPairMenuItem(args); + } + + private void AddDirectPairMenuItem(IMenuOpenedArgs args) + { + UiSharedService.AddContextMenuItem( + args, + name: "Send Direct Pair Request", + prefixChar: 'L', + colorMenuItem: _lightlessPrefixColor, + onClick: () => HandleSelection(args)); } private HashSet VisibleUserIds => - _pairUiService.GetSnapshot().PairsByUid.Values + [.. _pairUiService.GetSnapshot().PairsByUid.Values .Where(p => p.IsVisible && p.PlayerCharacterId != uint.MaxValue) - .Select(p => (ulong)p.PlayerCharacterId) - .ToHashSet(); + .Select(p => (ulong)p.PlayerCharacterId)]; private async Task HandleSelection(IMenuArgs args) { diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index b3734a3..2875acb 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -1,4 +1,6 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Game.Gui.ContextMenu; +using Dalamud.Game.Text.SeStringHandling; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.GameFonts; @@ -8,7 +10,6 @@ using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; -using System; using Dalamud.Plugin; using Dalamud.Plugin.Services; using Dalamud.Utility; @@ -25,6 +26,7 @@ using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR; using Microsoft.Extensions.Logging; +using System; using System.IdentityModel.Tokens.Jwt; using System.Numerics; using System.Runtime.InteropServices; @@ -487,6 +489,21 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ); } + public static void AddContextMenuItem(IMenuOpenedArgs args, SeString name, char prefixChar, ushort colorMenuItem, Func onClick) + { + args.AddMenuItem(new MenuItem + { + Name = name, + PrefixChar = prefixChar, + UseDefaultPrefix = false, + PrefixColor = colorMenuItem, + OnClicked = _ => + { + onClick(); + }, + }); + } + public static void ColoredSeparator(Vector4? color = null, float thickness = 1f, float indent = 0f) { var drawList = ImGui.GetWindowDrawList(); -- 2.49.1 From 0b36c1bdc29003518eabc19164d79b082100353c Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 00:00:39 +0100 Subject: [PATCH 057/140] Changed syncshell admin user list, added filter and copy. show creation date while hoving over text. --- LightlessSync/UI/SyncshellAdminUI.cs | 423 ++++++++++++++++++--------- 1 file changed, 279 insertions(+), 144 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 27da617..342e55b 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -16,6 +16,7 @@ using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using System.Globalization; +using System.Numerics; namespace LightlessSync.UI; @@ -30,6 +31,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private readonly PairUiService _pairUiService; private List _bannedUsers = []; private LightlessGroupProfileData? _profileData = null; + private string _userSearchFilter = string.Empty; private IDalamudTextureWrap? _pfpTextureWrap; private string _profileDescription = string.Empty; private int _multiInvites; @@ -84,10 +86,22 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } _profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group); - using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID); + using (_uiSharedService.UidFont.Push()) - _uiSharedService.UnderlinedBigText(GroupFullInfo.GroupAliasOrGID + " Administrative Panel", UIColors.Get("LightlessBlue")); + { + var headerText = $"{GroupFullInfo.GroupAliasOrGID} Administrative Panel"; + _uiSharedService.UnderlinedBigText(headerText, UIColors.Get("LightlessBlue")); + } + + if (ImGui.IsItemHovered()) + { + ImGui.BeginTooltip(); + ImGui.Text($"{GroupFullInfo.GroupAliasOrGID} is created at:"); + ImGui.Separator(); + ImGui.Text(text: GroupFullInfo.Group.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")); + ImGui.EndTooltip(); + } ImGui.Separator(); var perm = GroupFullInfo.GroupPermissions; @@ -264,151 +278,12 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } else { - var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; - if (pairs.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY; - using var table = ImRaii.Table("userList#" + GroupFullInfo.Group.AliasOrGID, 3, tableFlags); - if (table) - { - ImGui.TableSetupColumn("Alias/UID/Note", ImGuiTableColumnFlags.None, 4); - ImGui.TableSetupColumn("Flags", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.None, 3); - ImGui.TableHeadersRow(); - - var groupedPairs = new Dictionary(pairs.Select(p => new KeyValuePair(p, - GroupFullInfo.GroupPairUserInfos.TryGetValue(p.UserData.UID, out GroupPairUserInfo value) ? value : null))); - - foreach (var pair in groupedPairs.OrderBy(p => - { - if (p.Value == null) return 10; - if (string.Equals(p.Key.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal)) return 0; - if (p.Value.Value.IsModerator()) return 1; - if (p.Value.Value.IsPinned()) return 2; - return 10; - }).ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase)) - { - using var tableId = ImRaii.PushId("userTable_" + pair.Key.UserData.UID); - var isUserOwner = string.Equals(pair.Key.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal); - - ImGui.TableNextColumn(); // alias/uid/note - var note = pair.Key.GetNote(); - var text = note == null ? pair.Key.UserData.AliasOrUID : note + " (" + pair.Key.UserData.AliasOrUID + ")"; - ImGui.AlignTextToFramePadding(); - var boolcolor = UiSharedService.GetBoolColor(pair.Key.IsOnline); - UiSharedService.ColorText(text, boolcolor); - if (!string.IsNullOrEmpty(pair.Key.PlayerName)) - { - UiSharedService.AttachToolTip(pair.Key.PlayerName); - ImGui.SameLine(); - } - - ImGui.TableNextColumn(); // special flags - if (pair.Value != null && (pair.Value.Value.IsModerator() || pair.Value.Value.IsPinned() || isUserOwner)) - { - if (pair.Value.Value.IsModerator()) - { - _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); - UiSharedService.AttachToolTip("Moderator"); - } - if (pair.Value.Value.IsPinned() && !isUserOwner) - { - _uiSharedService.IconText(FontAwesomeIcon.Thumbtack); - UiSharedService.AttachToolTip("Pinned"); - } - if (isUserOwner) - { - _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); - UiSharedService.AttachToolTip("Owner"); - } - } - else - { - _uiSharedService.IconText(FontAwesomeIcon.None); - } - - ImGui.TableNextColumn(); // actions - if (_isOwner) - { - using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"))) - { - using (ImRaii.Disabled(!UiSharedService.ShiftPressed())) - { - if (_uiSharedService.IconButton(FontAwesomeIcon.Crown)) - { - _ = _apiController.GroupChangeOwnership(new(GroupFullInfo.Group, pair.Key.UserData)); - IsOpen = false; - } - } - - } - UiSharedService.AttachToolTip("Hold SHIFT and click to transfer ownership of this Syncshell to " - + (pair.Key.UserData.AliasOrUID) + Environment.NewLine + "WARNING: This action is irreversible and will close screen."); - ImGui.SameLine(); - - using (ImRaii.PushColor(ImGuiCol.Text, pair.Value != null && pair.Value.Value.IsModerator() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) - { - if (_uiSharedService.IconButton(FontAwesomeIcon.UserShield)) - { - GroupPairUserInfo userInfo = pair.Value ?? GroupPairUserInfo.None; - - userInfo.SetModerator(!userInfo.IsModerator()); - - _ = _apiController.GroupSetUserInfo(new GroupPairUserInfoDto(GroupFullInfo.Group, pair.Key.UserData, userInfo)); - } - } - UiSharedService.AttachToolTip(pair.Value != null && pair.Value.Value.IsModerator() ? "Demod user" : "Mod user"); - ImGui.SameLine(); - } - - if (pair.Value == null || pair.Value != null && !pair.Value.Value.IsModerator() && !isUserOwner) - { - using (ImRaii.PushColor(ImGuiCol.Text, pair.Value != null && pair.Value.Value.IsPinned() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) - { - if (_uiSharedService.IconButton(FontAwesomeIcon.Thumbtack)) - { - GroupPairUserInfo userInfo = pair.Value ?? GroupPairUserInfo.None; - - userInfo.SetPinned(!userInfo.IsPinned()); - - _ = _apiController.GroupSetUserInfo(new GroupPairUserInfoDto(GroupFullInfo.Group, pair.Key.UserData, userInfo)); - } - } - UiSharedService.AttachToolTip(pair.Value != null && pair.Value.Value.IsPinned() ? "Unpin user" : "Pin user"); - ImGui.SameLine(); - - using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) - { - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) - { - _ = _apiController.GroupRemoveUser(new GroupPairDto(GroupFullInfo.Group, pair.Key.UserData)); - } - } - } - UiSharedService.AttachToolTip("Remove user from Syncshell" - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - ImGui.SameLine(); - - using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) - { - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconButton(FontAwesomeIcon.Ban)) - { - Mediator.Publish(new OpenBanUserPopupMessage(pair.Key, GroupFullInfo)); - } - } - } - UiSharedService.AttachToolTip("Ban user from Syncshell" - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - } - } - } + DrawUserListCustom(pairs, GroupFullInfo); } - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + ImGui.Separator(); } - ImGui.Separator(); if (_uiSharedService.MediumTreeNode("Mass Cleanup", UIColors.Get("DimRed"))) { @@ -595,6 +470,266 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase inviteTab.Dispose(); } + private void DrawUserListCustom(IReadOnlyList pairs, GroupFullInfoDto GroupFullInfo) + { + ImGui.PushItemWidth(0); + _uiSharedService.IconText(FontAwesomeIcon.Search, UIColors.Get("LightlessPurple")); + ImGui.SameLine(); + + ImGui.InputTextWithHint( + "##UserSearchFilter", + "Search UID/alias or note...", + ref _userSearchFilter, + 64); + + ImGui.PopItemWidth(); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + + var groupedPairs = new Dictionary( + pairs.Select(p => new KeyValuePair( + p, + GroupFullInfo.GroupPairUserInfos.TryGetValue(p.UserData.UID, out var value) ? value : null + )) + ); + + var filter = _userSearchFilter?.Trim(); + bool hasFilter = !string.IsNullOrEmpty(filter); + if (hasFilter) + filter = filter!.ToLowerInvariant(); + + var orderedPairs = groupedPairs + .Where(p => !hasFilter || MatchesUserFilter(p.Key, filter!)) + .OrderBy(p => + { + if (p.Value == null) return 10; + if (string.Equals(p.Key.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal)) return 0; + if (p.Value.Value.IsModerator()) return 1; + if (p.Value.Value.IsPinned()) return 2; + return 10; + }) + .ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase); + + var style = ImGui.GetStyle(); + float fullW = ImGui.GetContentRegionAvail().X; + float colUid = fullW * 0.50f; + float colFlags = fullW * 0.10f; + float colActions = fullW - colUid - colFlags - style.ItemSpacing.X * 2.5f; + + DrawUserListHeader(colUid, colFlags); + + bool useScroll = pairs.Count > 10; + float childHeight = useScroll ? 260f * ImGuiHelpers.GlobalScale : 0f; + + ImGui.BeginChild("userListScroll#" + GroupFullInfo.Group.AliasOrGID, new Vector2(0, childHeight), true); + + int rowIndex = 0; + foreach (var kv in orderedPairs) + { + var pair = kv.Key; + var userInfoOpt = kv.Value; + DrawUserRowCustom(pair, userInfoOpt, GroupFullInfo, rowIndex++, colUid, colFlags, colActions); + } + ImGui.EndChild(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + } + + private static void DrawUserListHeader(float colUid, float colFlags) + { + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessPurple")); + + // Alias/UID/Note + ImGui.SetCursorPosX(x0); + ImGui.TextUnformatted("Alias / UID / Note"); + + // User Flags + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colUid + style.ItemSpacing.X); + ImGui.TextUnformatted("Flags"); + + // User Actions + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.5f); + ImGui.TextUnformatted("Actions"); + + ImGui.PopStyleColor(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + } + private void DrawUserRowCustom(Pair pair, GroupPairUserInfo? userInfoOpt, GroupFullInfoDto GroupFullInfo, int rowIndex, float colUid, float colFlags, float colActions) + { + using var id = ImRaii.PushId("userRow_" + pair.UserData.UID); + + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + if (rowIndex % 2 == 0) + { + var drawList = ImGui.GetWindowDrawList(); + var pMin = ImGui.GetCursorScreenPos(); + var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.5f; + var pMax = new Vector2(pMin.X + colUid + colFlags + colActions + style.ItemSpacing.X * 2.0f, + pMin.Y + rowHeight); + + var bgColor = UIColors.Get("FullBlack") with { W = 0.0f }; + drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + } + + var isUserOwner = string.Equals(pair.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal); + var userInfo = userInfoOpt ?? GroupPairUserInfo.None; + + ImGui.SetCursorPosX(x0); + ImGui.AlignTextToFramePadding(); + + var note = pair.GetNote(); + var text = note == null + ? pair.UserData.AliasOrUID + : $"{note} ({pair.UserData.AliasOrUID})"; + + var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline); + UiSharedService.ColorText(text, boolcolor); + if (ImGui.IsItemClicked()) + { + ImGui.SetClipboardText(text); + } + + if (!string.IsNullOrEmpty(pair.PlayerName)) + { + UiSharedService.AttachToolTip(pair.PlayerName); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colUid + style.ItemSpacing.X); + + if (userInfoOpt != null && (userInfo.IsModerator() || userInfo.IsPinned() || isUserOwner)) + { + if (userInfo.IsModerator()) + { + _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); + UiSharedService.AttachToolTip("Moderator"); + } + if (userInfo.IsPinned() && !isUserOwner) + { + _uiSharedService.IconText(FontAwesomeIcon.Thumbtack); + UiSharedService.AttachToolTip("Pinned"); + } + if (isUserOwner) + { + _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("Owner"); + } + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.None); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.0f); + + DrawUserActions(pair, GroupFullInfo, userInfo, isUserOwner); + + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + + private void DrawUserActions(Pair pair, GroupFullInfoDto GroupFullInfo, GroupPairUserInfo userInfo, bool isUserOwner) + { + if (_isOwner) + { + // Transfer ownership to user + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"))) + using (ImRaii.Disabled(!UiSharedService.ShiftPressed())) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Crown)) + { + _ = _apiController.GroupChangeOwnership(new(GroupFullInfo.Group, pair.UserData)); + IsOpen = false; + } + } + + UiSharedService.AttachToolTip("Hold SHIFT and click to transfer ownership of this Syncshell to " + + pair.UserData.AliasOrUID + Environment.NewLine + + "WARNING: This action is irreversible and will close screen."); + ImGui.SameLine(); + + // Mod / Demod user + using (ImRaii.PushColor(ImGuiCol.Text, + userInfo.IsModerator() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.UserShield)) + { + userInfo.SetModerator(!userInfo.IsModerator()); + _ = _apiController.GroupSetUserInfo( + new GroupPairUserInfoDto(GroupFullInfo.Group, pair.UserData, userInfo)); + } + } + + UiSharedService.AttachToolTip( + userInfo.IsModerator() ? $"Demod {pair.UserData.AliasOrUID}" : $"Mod {pair.UserData.AliasOrUID}"); + ImGui.SameLine(); + } + + if (userInfo == GroupPairUserInfo.None || (!userInfo.IsModerator() && !isUserOwner)) + { + // Pin user + using (ImRaii.PushColor(ImGuiCol.Text, + userInfo.IsPinned() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Thumbtack)) + { + userInfo.SetPinned(!userInfo.IsPinned()); + + _ = _apiController.GroupSetUserInfo( + new GroupPairUserInfoDto(GroupFullInfo.Group, pair.UserData, userInfo)); + } + } + + UiSharedService.AttachToolTip( + userInfo.IsPinned() ? $"Unpin {pair.UserData.AliasOrUID}" : $"Pin {pair.UserData.AliasOrUID}"); + ImGui.SameLine(); + + // Remove user + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) + { + _ = _apiController.GroupRemoveUser(new GroupPairDto(GroupFullInfo.Group, pair.UserData)); + } + } + + UiSharedService.AttachToolTip($"Remove {pair.UserData.AliasOrUID} from Syncshell" + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + ImGui.SameLine(); + + // Ban user + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconButton(FontAwesomeIcon.Ban)) + { + Mediator.Publish(new OpenBanUserPopupMessage(pair, GroupFullInfo)); + } + } + + UiSharedService.AttachToolTip($"Ban {pair.UserData.AliasOrUID} from Syncshell" + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + } + } + + private static bool MatchesUserFilter(Pair pair, string filterLower) + { + var note = pair.GetNote() ?? string.Empty; + var uid = pair.UserData.UID ?? string.Empty; + var alias = pair.UserData.AliasOrUID ?? string.Empty; + + return note.Contains(filterLower, StringComparison.OrdinalIgnoreCase) + || uid.Contains(filterLower, StringComparison.OrdinalIgnoreCase) + || alias.Contains(filterLower, StringComparison.OrdinalIgnoreCase); + } + public override void OnClose() { Mediator.Publish(new RemoveWindowMessage(this)); -- 2.49.1 From 04cd09cbb9c908eeaf673c871740135619798328 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 00:18:36 +0100 Subject: [PATCH 058/140] Fixed seperator --- LightlessSync/UI/SyncshellAdminUI.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 342e55b..522fa2a 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -282,8 +282,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } ImGui.TreePop(); - ImGui.Separator(); } + ImGui.Separator(); if (_uiSharedService.MediumTreeNode("Mass Cleanup", UIColors.Get("DimRed"))) { -- 2.49.1 From cab13874d84bc29305ffd17c259120cc0806fbdf Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 01:26:18 +0100 Subject: [PATCH 059/140] Allow moderators to use shell broadcasting, distinct shell finder to remove duplicate shells --- LightlessSync/UI/BroadcastUI.cs | 9 +++++---- LightlessSync/UI/SyncshellAdminUI.cs | 2 ++ LightlessSync/UI/SyncshellFinderUI.cs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/BroadcastUI.cs index 5540b02..1efb1fa 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/BroadcastUI.cs @@ -3,6 +3,7 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Utility; +using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; @@ -55,9 +56,9 @@ namespace LightlessSync.UI private void RebuildSyncshellDropdownOptions() { var selectedGid = _configService.Current.SelectedFinderSyncshell; - var allSyncshells = _allSyncshells ?? Array.Empty(); - var ownedSyncshells = allSyncshells - .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal)) + var allSyncshells = _allSyncshells ?? []; + var filteredSyncshells = allSyncshells + .Where(g => string.Equals(g.OwnerUID, _userUid, StringComparison.Ordinal) || g.GroupUserInfo.IsModerator()) .ToList(); _syncshellOptions.Clear(); @@ -65,7 +66,7 @@ namespace LightlessSync.UI var addedGids = new HashSet(StringComparer.Ordinal); - foreach (var shell in ownedSyncshells) + foreach (var shell in filteredSyncshells) { var label = shell.GroupAliasOrGID ?? shell.GID; _syncshellOptions.Add((label, shell.GID, true)); diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 522fa2a..e190193 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -12,6 +12,7 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.Profiles; using LightlessSync.UI.Services; +using LightlessSync.UI.Style; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; @@ -558,6 +559,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); } + private void DrawUserRowCustom(Pair pair, GroupPairUserInfo? userInfoOpt, GroupFullInfoDto GroupFullInfo, int rowIndex, float colUid, float colFlags, float colActions) { using var id = ImRaii.PushId("userRow_" + pair.UserData.UID); diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 0ebfdef..4d4dca7 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -498,7 +498,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase { var groups = await _apiController.GetBroadcastedGroups(syncshellBroadcasts) .ConfigureAwait(false); - updatedList = groups?.ToList(); + updatedList = groups?.DistinctBy(g => g.Group.GID).ToList(); } catch (Exception ex) { -- 2.49.1 From a9181d2592782292265398a99d612a48681176cf Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 08:09:58 +0100 Subject: [PATCH 060/140] Removed obselete functions, changed download bars a bit. renamed files correctly --- LightlessSync/FileCache/FileCacheManager.cs | 3 +- .../Factories/FileDownloadManagerFactory.cs | 2 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 1 + LightlessSync/Plugin.cs | 22 +- LightlessSync/Services/CharacterAnalyzer.cs | 2 +- .../Services/CommandManagerService.cs | 2 +- LightlessSync/Services/ContextMenuService.cs | 9 +- LightlessSync/Services/DalamudUtilService.cs | 24 +- .../LightFinderScannerService.cs} | 12 +- .../LightFinderService.cs} | 18 +- LightlessSync/Services/NameplateHandler.cs | 8 +- LightlessSync/Services/NotificationService.cs | 2 +- .../PairProcessingLimiter.cs | 3 +- .../Services/PlayerPerformanceService.cs | 3 - .../{ => Profiles}/LightlessProfileData.cs | 0 .../{ => Profiles}/LightlessProfileManager.cs | 8 + LightlessSync/UI/CompactUI.cs | 17 +- LightlessSync/UI/DownloadUi.cs | 378 ++++++++++++------ LightlessSync/UI/DtrEntry.cs | 9 +- .../UI/{BroadcastUI.cs => LightFinderUI.cs} | 15 +- LightlessSync/UI/SettingsUi.cs | 1 + LightlessSync/UI/SyncshellFinderUI.cs | 11 +- LightlessSync/UI/TopTabMenu.cs | 2 +- .../WebAPI/Files/FileDownloadManager.cs | 2 +- 24 files changed, 357 insertions(+), 197 deletions(-) rename LightlessSync/Services/{BroadcastScannerService.cs => LightFinder/LightFinderScannerService.cs} (95%) rename LightlessSync/Services/{BroadcastService.cs => LightFinder/LightFinderService.cs} (96%) rename LightlessSync/Services/{ => PairProcessing}/PairProcessingLimiter.cs (98%) rename LightlessSync/Services/{ => Profiles}/LightlessProfileData.cs (100%) rename LightlessSync/Services/{ => Profiles}/LightlessProfileManager.cs (99%) rename LightlessSync/UI/{BroadcastUI.cs => LightFinderUI.cs} (97%) diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index ff45c6a..e8b0cb8 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -636,8 +636,7 @@ public sealed class FileCacheManager : IHostedService private FileCacheEntity? CreateFileCacheEntity(FileInfo fileInfo, string prefixedPath, string? hash = null) { - var algo = Crypto.DetectAlgo(Path.GetFileNameWithoutExtension(fileInfo.Name)); - hash ??= Crypto.ComputeFileHash(fileInfo.FullName, algo); + hash ??= Crypto.ComputeFileHash(fileInfo.FullName, Crypto.HashAlgo.Sha1); var entity = new FileCacheEntity(hash, prefixedPath, fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture), fileInfo.Length); entity = ReplacePathPrefixes(entity); AddHashedFile(entity); diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index 81e3ecb..f9b522a 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -1,7 +1,7 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; -using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index ad77bcb..c63bf31 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -10,6 +10,7 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.TextureCompression; using LightlessSync.Utils; diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 6e68f77..41d8569 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -38,6 +38,8 @@ using System.IO; using System.Net.Http.Headers; using System.Reflection; using OtterTex; +using LightlessSync.Services.LightFinder; +using LightlessSync.Services.PairProcessing; namespace LightlessSync; @@ -189,8 +191,8 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), + s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton(s => new PairCoordinator( s.GetRequiredService>(), @@ -201,15 +203,15 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton(); - collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(addonLifecycle); collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData, p.GetRequiredService>(), p.GetRequiredService(), p.GetRequiredService(), objectTable, p.GetRequiredService(), p.GetRequiredService(), p.GetRequiredService(), clientState, - p.GetRequiredService(), - p.GetRequiredService(), + p.GetRequiredService(), + p.GetRequiredService(), p.GetRequiredService(), p.GetRequiredService())); collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService>(), pluginInterface, @@ -288,7 +290,7 @@ public sealed class Plugin : IDalamudPlugin clientState, sp.GetRequiredService())); collection.AddSingleton(); - collection.AddSingleton(s => new BroadcastScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddSingleton(s => new LightFinderScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); // add scoped services @@ -314,8 +316,8 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); - collection.AddScoped((s) => new BroadcastUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new LightFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped(); collection.AddScoped((s) => new LightlessNotificationUi( @@ -343,7 +345,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), clientState, gameGui, objectTable, gameInteropProvider, s.GetRequiredService(),s.GetRequiredService())); collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, - s.GetRequiredService(), s.GetRequiredService(), clientState, s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), objectTable, s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); @@ -359,7 +361,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); }) .Build(); diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index a56b6e3..2a0aa04 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -116,7 +116,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { foreach (var entry in objectEntries.Values) { - if (!entry.FilePaths.Any(path => normalized.Contains(path))) + if (!entry.FilePaths.Exists(path => normalized.Contains(path))) { continue; } diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index 88f8780..51c57f9 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -131,7 +131,7 @@ public sealed class CommandManagerService : IDisposable } else if (string.Equals(splitArgs[0], "finder", StringComparison.OrdinalIgnoreCase)) { - _mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + _mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } } } \ No newline at end of file diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 78e34a8..740f52b 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -12,6 +12,7 @@ using LightlessSync.UI.Services; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using LightlessSync.UI; +using LightlessSync.Services.LightFinder; namespace LightlessSync.Services; @@ -28,8 +29,8 @@ internal class ContextMenuService : IHostedService private readonly ApiController _apiController; private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; - private readonly BroadcastScannerService _broadcastScannerService; - private readonly BroadcastService _broadcastService; + private readonly LightFinderScannerService _broadcastScannerService; + private readonly LightFinderService _broadcastService; private readonly LightlessProfileManager _lightlessProfileManager; private readonly LightlessMediator _mediator; @@ -47,8 +48,8 @@ internal class ContextMenuService : IHostedService PairRequestService pairRequestService, PairUiService pairUiService, IClientState clientState, - BroadcastScannerService broadcastScannerService, - BroadcastService broadcastService, + LightFinderScannerService broadcastScannerService, + LightFinderService broadcastService, LightlessProfileManager lightlessProfileManager, LightlessMediator mediator) { diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 66045a1..3a2cb94 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -14,14 +14,12 @@ using LightlessSync.Interop; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Lumina.Excel.Sheets; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using System.Linq; using System.Numerics; using System.Runtime.CompilerServices; using System.Text; @@ -249,7 +247,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public bool GetIsPlayerPresent() { EnsureIsOnFramework(); - return _clientState.LocalPlayer != null && _clientState.LocalPlayer.IsValid(); + return _objectTable.LocalPlayer != null && _objectTable.LocalPlayer.IsValid(); } public async Task GetIsPlayerPresentAsync() @@ -293,7 +291,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IPlayerCharacter GetPlayerCharacter() { EnsureIsOnFramework(); - return _clientState.LocalPlayer!; + return _objectTable.LocalPlayer!; } public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName) @@ -306,7 +304,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public string GetPlayerName() { EnsureIsOnFramework(); - return _clientState.LocalPlayer?.Name.ToString() ?? "--"; + return _objectTable.LocalPlayer?.Name.ToString() ?? "--"; } public async Task GetPlayerNameAsync() @@ -339,7 +337,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IntPtr GetPlayerPtr() { EnsureIsOnFramework(); - return _clientState.LocalPlayer?.Address ?? IntPtr.Zero; + return _objectTable.LocalPlayer?.Address ?? IntPtr.Zero; } public async Task GetPlayerPointerAsync() @@ -350,13 +348,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public uint GetHomeWorldId() { EnsureIsOnFramework(); - return _clientState.LocalPlayer?.HomeWorld.RowId ?? 0; + return _objectTable.LocalPlayer?.HomeWorld.RowId ?? 0; } public uint GetWorldId() { EnsureIsOnFramework(); - return _clientState.LocalPlayer!.CurrentWorld.RowId; + return _objectTable.LocalPlayer!.CurrentWorld.RowId; } public unsafe LocationInfo GetMapData() @@ -365,8 +363,8 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var agentMap = AgentMap.Instance(); var houseMan = HousingManager.Instance(); uint serverId = 0; - if (_clientState.LocalPlayer == null) serverId = 0; - else serverId = _clientState.LocalPlayer.CurrentWorld.RowId; + if (_objectTable.LocalPlayer == null) serverId = 0; + else serverId = _objectTable.LocalPlayer.CurrentWorld.RowId; uint mapId = agentMap == null ? 0 : agentMap->CurrentMapId; uint territoryId = agentMap == null ? 0 : agentMap->CurrentTerritoryId; uint divisionId = houseMan == null ? 0 : (uint)(houseMan->GetCurrentDivision()); @@ -494,7 +492,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _framework.Update += FrameworkOnUpdate; if (IsLoggedIn) { - _classJobId = _clientState.LocalPlayer!.ClassJob.RowId; + _classJobId = _objectTable.LocalPlayer!.ClassJob.RowId; } _logger.LogInformation("Started DalamudUtilService"); @@ -647,7 +645,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private unsafe void FrameworkOnUpdateInternal() { - if ((_clientState.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) + if ((_objectTable.LocalPlayer?.IsDead ?? false) && _condition[ConditionFlag.BoundByDuty]) { return; } @@ -805,7 +803,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.Publish(new ResumeScanMessage(nameof(ConditionFlag.BetweenAreas))); } - var localPlayer = _clientState.LocalPlayer; + var localPlayer = _objectTable.LocalPlayer; if (localPlayer != null) { _classJobId = localPlayer.ClassJob.RowId; diff --git a/LightlessSync/Services/BroadcastScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs similarity index 95% rename from LightlessSync/Services/BroadcastScannerService.cs rename to LightlessSync/Services/LightFinder/LightFinderScannerService.cs index 96576d1..a0ea3e6 100644 --- a/LightlessSync/Services/BroadcastScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -5,15 +5,15 @@ using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; -namespace LightlessSync.Services; +namespace LightlessSync.Services.LightFinder; -public class BroadcastScannerService : DisposableMediatorSubscriberBase +public class LightFinderScannerService : DisposableMediatorSubscriberBase { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ActorObjectService _actorTracker; private readonly IFramework _framework; - private readonly BroadcastService _broadcastService; + private readonly LightFinderService _broadcastService; private readonly NameplateHandler _nameplateHandler; private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); @@ -37,9 +37,9 @@ public class BroadcastScannerService : DisposableMediatorSubscriberBase public IReadOnlyDictionary BroadcastCache => _broadcastCache; public readonly record struct BroadcastEntry(bool IsBroadcasting, DateTime ExpiryTime, string? GID); - public BroadcastScannerService(ILogger logger, + public LightFinderScannerService(ILogger logger, IFramework framework, - BroadcastService broadcastService, + LightFinderService broadcastService, LightlessMediator mediator, NameplateHandler nameplateHandler, ActorObjectService actorTracker) : base(logger, mediator) diff --git a/LightlessSync/Services/BroadcastService.cs b/LightlessSync/Services/LightFinder/LightFinderService.cs similarity index 96% rename from LightlessSync/Services/BroadcastService.cs rename to LightlessSync/Services/LightFinder/LightFinderService.cs index bc32c9c..82a51c7 100644 --- a/LightlessSync/Services/BroadcastService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderService.cs @@ -1,21 +1,21 @@ using Dalamud.Interface; -using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.UI; -using LightlessSync.UI.Models; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services.Mediator; +using LightlessSync.UI; +using LightlessSync.UI.Models; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -namespace LightlessSync.Services; -public class BroadcastService : IHostedService, IMediatorSubscriber +namespace LightlessSync.Services.LightFinder; +public class LightFinderService : IHostedService, IMediatorSubscriber { - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ApiController _apiController; private readonly LightlessMediator _mediator; private readonly LightlessConfigService _config; @@ -44,7 +44,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber } } - public BroadcastService(ILogger logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController) + public LightFinderService(ILogger logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController) { _logger = logger; _mediator = mediator; @@ -281,7 +281,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber if (!msg.Enabled) { ApplyBroadcastDisabled(forcePublish: true); - Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}"))); + Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}"))); return; } @@ -294,7 +294,7 @@ public class BroadcastService : IHostedService, IMediatorSubscriber if (TryApplyBroadcastEnabled(ttl, "client request")) { _logger.LogDebug("Fetched TTL from server: {TTL}", ttl); - Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); + Mediator.Publish(new EventMessage(new Events.Event(nameof(LightFinderService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); } else { diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs index f117da9..808242d 100644 --- a/LightlessSync/Services/NameplateHandler.cs +++ b/LightlessSync/Services/NameplateHandler.cs @@ -26,7 +26,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber private readonly ILogger _logger; private readonly IAddonLifecycle _addonLifecycle; private readonly IGameGui _gameGui; - private readonly IClientState _clientState; + private readonly IObjectTable _objectTable; private readonly LightlessConfigService _configService; private readonly PairUiService _pairUiService; private readonly LightlessMediator _mediator; @@ -48,14 +48,14 @@ public unsafe class NameplateHandler : IMediatorSubscriber private ImmutableHashSet _activeBroadcastingCids = []; - public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, LightlessConfigService configService, LightlessMediator mediator, IClientState clientState, PairUiService pairUiService) + public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, LightlessConfigService configService, LightlessMediator mediator, IObjectTable objectTable, PairUiService pairUiService) { _logger = logger; _addonLifecycle = addonLifecycle; _gameGui = gameGui; _configService = configService; _mediator = mediator; - _clientState = clientState; + _objectTable = objectTable; _pairUiService = pairUiService; System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); @@ -323,7 +323,7 @@ public unsafe class NameplateHandler : IMediatorSubscriber continue; } - var local = _clientState.LocalPlayer; + var local = _objectTable.LocalPlayer; if (!config.LightfinderLabelShowOwn && local != null && objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) { diff --git a/LightlessSync/Services/NotificationService.cs b/LightlessSync/Services/NotificationService.cs index 02b5b05..cedb58b 100644 --- a/LightlessSync/Services/NotificationService.cs +++ b/LightlessSync/Services/NotificationService.cs @@ -28,7 +28,7 @@ public class NotificationService : DisposableMediatorSubscriberBase, IHostedServ private readonly INotificationManager _notificationManager; private readonly IChatGui _chatGui; private readonly PairRequestService _pairRequestService; - private readonly HashSet _shownPairRequestNotifications = new(); + private readonly HashSet _shownPairRequestNotifications = []; private readonly PairUiService _pairUiService; private readonly PairFactory _pairFactory; diff --git a/LightlessSync/Services/PairProcessingLimiter.cs b/LightlessSync/Services/PairProcessing/PairProcessingLimiter.cs similarity index 98% rename from LightlessSync/Services/PairProcessingLimiter.cs rename to LightlessSync/Services/PairProcessing/PairProcessingLimiter.cs index 35b6d1c..1a860d3 100644 --- a/LightlessSync/Services/PairProcessingLimiter.cs +++ b/LightlessSync/Services/PairProcessing/PairProcessingLimiter.cs @@ -1,9 +1,8 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; -using LightlessSync.Services.PairProcessing; using Microsoft.Extensions.Logging; -namespace LightlessSync.Services; +namespace LightlessSync.Services.PairProcessing; public sealed class PairProcessingLimiter : DisposableMediatorSubscriberBase { diff --git a/LightlessSync/Services/PlayerPerformanceService.cs b/LightlessSync/Services/PlayerPerformanceService.cs index 9382cf7..e77ccd7 100644 --- a/LightlessSync/Services/PlayerPerformanceService.cs +++ b/LightlessSync/Services/PlayerPerformanceService.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; using LightlessSync.API.Data; -using LightlessSync.API.Data.Extensions; using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; diff --git a/LightlessSync/Services/LightlessProfileData.cs b/LightlessSync/Services/Profiles/LightlessProfileData.cs similarity index 100% rename from LightlessSync/Services/LightlessProfileData.cs rename to LightlessSync/Services/Profiles/LightlessProfileData.cs diff --git a/LightlessSync/Services/LightlessProfileManager.cs b/LightlessSync/Services/Profiles/LightlessProfileManager.cs similarity index 99% rename from LightlessSync/Services/LightlessProfileManager.cs rename to LightlessSync/Services/Profiles/LightlessProfileManager.cs index 7d60d66..2f854e8 100644 --- a/LightlessSync/Services/LightlessProfileManager.cs +++ b/LightlessSync/Services/Profiles/LightlessProfileManager.cs @@ -39,6 +39,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _noUserDescription, Tags: _emptyTagSet); + private readonly LightlessUserProfileData _loadingProfileUserData = new( IsFlagged: false, IsNSFW: false, @@ -47,6 +48,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _loadingData, Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _loadingProfileGroupData = new( IsDisabled: false, IsNsfw: false, @@ -54,6 +56,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _loadingData, Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _defaultProfileGroupData = new( IsDisabled: false, IsNsfw: false, @@ -61,6 +64,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _noGroupDescription, Tags: _emptyTagSet); + private readonly LightlessUserProfileData _nsfwProfileUserData = new( IsFlagged: false, IsNSFW: true, @@ -69,6 +73,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: string.Empty, Description: _nsfwDescription, Tags: _emptyTagSet); + private readonly LightlessGroupProfileData _nsfwProfileGroupData = new( IsDisabled: false, IsNsfw: true, @@ -76,6 +81,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: string.Empty, Description: _nsfwDescription, Tags: _emptyTagSet); + private const string _noDescription = "-- Profile has no description set --"; private readonly ConcurrentDictionary _lightlessProfiles = new(UserDataComparer.Instance); private readonly LightlessProfileData _defaultProfileData = new( @@ -86,6 +92,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _noDescription, Tags: _emptyTagSet); + private readonly LightlessProfileData _loadingProfileData = new( IsFlagged: false, IsNSFW: false, @@ -94,6 +101,7 @@ public class LightlessProfileManager : MediatorSubscriberBase Base64BannerPicture: _lightlessBanner, Description: _loadingData, Tags: _emptyTagSet); + private readonly LightlessProfileData _nsfwProfileData = new( IsFlagged: false, IsNSFW: false, diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 4c46fd4..d2715c9 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -9,6 +9,7 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Components; @@ -54,7 +55,7 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly TopTabMenu _tabMenu; private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; - private readonly BroadcastService _broadcastService; + private readonly LightFinderService _broadcastService; private List _drawFolders; private Pair? _lastAddedUser; @@ -66,7 +67,7 @@ public class CompactUi : WindowMediatorSubscriberBase private bool _wasOpen; private float _windowContentWidth; private readonly SeluneBrush _seluneBrush = new(); - private const float ConnectButtonHighlightThickness = 14f; + private const float _connectButtonHighlightThickness = 14f; public CompactUi( ILogger logger, @@ -87,7 +88,7 @@ public class CompactUi : WindowMediatorSubscriberBase RenameSyncshellTagUi renameSyncshellTagUi, PerformanceCollectorService performanceCollectorService, IpcManager ipcManager, - BroadcastService broadcastService, + LightFinderService broadcastService, CharacterAnalyzer characterAnalyzer, PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { @@ -112,8 +113,8 @@ public class CompactUi : WindowMediatorSubscriberBase AllowPinning = true; AllowClickthrough = false; - TitleBarButtons = new() - { + TitleBarButtons = + [ new TitleBarButton() { Icon = FontAwesomeIcon.Cog, @@ -144,7 +145,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.EndTooltip(); } }, - }; + ]; _drawFolders = [.. DrawFolders]; @@ -406,7 +407,7 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.GetItemRectMax(), SeluneHighlightMode.Both, borderOnly: true, - borderThicknessOverride: ConnectButtonHighlightThickness, + borderThicknessOverride: _connectButtonHighlightThickness, exactSize: true, clipToElement: true, roundingOverride: ImGui.GetStyle().FrameRounding); @@ -634,7 +635,7 @@ public class CompactUi : WindowMediatorSubscriberBase } if (ImGui.IsItemClicked()) - _lightlessMediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } ImGui.SetCursorPosY(cursorY); diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 1902124..6d6d7dd 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -24,6 +24,15 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); private readonly Dictionary _smoothed = []; + private readonly Dictionary _downloadSpeeds = new(); + + private sealed class DownloadSpeedTracker + { + public long LastBytes; + public double LastTime; + public double SpeedBytesPerSecond; + } + private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; @@ -96,117 +105,52 @@ public class DownloadUi : WindowMediatorSubscriberBase { if (_configService.Current.ShowTransferWindow) { - var limiterSnapshot = _pairProcessingLimiter.GetSnapshot(); - - try + DrawDownloadSummaryBox(); + + if (_configService.Current.ShowUploading) { - if (_fileTransferManager.IsUploading) + const int transparency = 100; + foreach (var player in _uploadingPlayers.Select(p => p.Key).ToList()) { - var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); - var totalUploads = currentUploads.Count; + var screenPos = _dalamudUtilService.WorldToScreen(player.GetGameObject()); + if (screenPos == Vector2.Zero) continue; - var doneUploads = currentUploads.Count(c => c.IsTransferred); - var totalUploaded = currentUploads.Sum(c => c.Transferred); - var totalToUpload = currentUploads.Sum(c => c.Total); + try + { + using var _ = _uiShared.UidFont.Push(); + var uploadText = "Uploading"; + var textSize = ImGui.CalcTextSize(uploadText); - UiSharedService.DrawOutlinedFont($"▲", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.SameLine(); - var xDistance = ImGui.GetCursorPosX(); - UiSharedService.DrawOutlinedFont($"Compressing+Uploading {doneUploads}/{totalUploads}", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - ImGui.SameLine(xDistance); - UiSharedService.DrawOutlinedFont( - $"{UiSharedService.ByteToString(totalUploaded, addSuffix: false)}/{UiSharedService.ByteToString(totalToUpload)}", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - - if (_currentDownloads.Any()) ImGui.Separator(); - } - } - catch - { - _logger.LogDebug("Error drawing upload progress"); - } - - try - { - // Check if download notifications are enabled (not set to TextOverlay) - var useNotifications = _configService.Current.UseLightlessNotifications - ? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay - : _configService.Current.UseNotificationsForDownloads; - - if (useNotifications) - { - // Use notification system - if (_currentDownloads.Any()) - { - UpdateDownloadNotificationIfChanged(limiterSnapshot); - _notificationDismissed = false; - } - else if (!_notificationDismissed) - { - Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); - _notificationDismissed = true; - _lastDownloadStateHash = 0; - } - } - else - { - // Use text overlay - if (limiterSnapshot.IsEnabled) - { - var queueColor = limiterSnapshot.Waiting > 0 ? ImGuiColors.DalamudYellow : ImGuiColors.DalamudGrey; - var queueText = $"Pair queue {limiterSnapshot.InFlight}/{limiterSnapshot.Limit}"; - queueText += limiterSnapshot.Waiting > 0 ? $" ({limiterSnapshot.Waiting} waiting, {limiterSnapshot.Remaining} free)" : $" ({limiterSnapshot.Remaining} free)"; - UiSharedService.DrawOutlinedFont(queueText, queueColor, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - } - else - { - UiSharedService.DrawOutlinedFont("Pair apply limiter disabled", ImGuiColors.DalamudGrey, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - } - - foreach (var item in _currentDownloads.ToList()) - { - var dlSlot = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForSlot); - var dlQueue = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.WaitingForQueue); - var dlProg = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Downloading); - var dlDecomp = item.Value.Count(c => c.Value.DownloadStatus == DownloadStatus.Decompressing); - var totalFiles = item.Value.Sum(c => c.Value.TotalFiles); - var transferredFiles = item.Value.Sum(c => c.Value.TransferredFiles); - var totalBytes = item.Value.Sum(c => c.Value.TotalBytes); - var transferredBytes = item.Value.Sum(c => c.Value.TransferredBytes); - - UiSharedService.DrawOutlinedFont($"▼", ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.SameLine(); - var xDistance = ImGui.GetCursorPosX(); + var drawList = ImGui.GetBackgroundDrawList(); UiSharedService.DrawOutlinedFont( - $"{item.Key.Name} [W:{dlSlot}/Q:{dlQueue}/P:{dlProg}/D:{dlDecomp}]", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); - ImGui.NewLine(); - ImGui.SameLine(xDistance); - UiSharedService.DrawOutlinedFont( - $"{transferredFiles}/{totalFiles} ({UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)})", - ImGuiColors.DalamudWhite, new Vector4(0, 0, 0, 255), 1); + drawList, + uploadText, + screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, + UiSharedService.Color(255, 255, 0, transparency), + UiSharedService.Color(0, 0, 0, transparency), + 2 + ); + } + catch + { + _logger.LogDebug("Error drawing upload progress"); } } } - catch - { - _logger.LogDebug("Error drawing download progress"); - } } if (_configService.Current.ShowTransferBars) { const int transparency = 100; const int dlBarBorder = 3; + const float rounding = 6f; + var shadowOffset = new Vector2(2, 2); foreach (var transfer in _currentDownloads.ToList()) { var transferKey = transfer.Key; var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); + //If RawPos is zero, remove it from smoothed dictionary if (rawPos == Vector2.Zero) { @@ -214,43 +158,66 @@ public class DownloadUi : WindowMediatorSubscriberBase continue; } //Smoothing out the movement and fix jitter around the position. - Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos : rawPos; - _smoothed[transferKey] = screenPos; + Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) + ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos + : rawPos; + _smoothed[transferKey] = screenPos; var totalBytes = transfer.Value.Sum(c => c.Value.TotalBytes); var transferredBytes = transfer.Value.Sum(c => c.Value.TransferredBytes); var maxDlText = $"{UiSharedService.ByteToString(totalBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; - var textSize = _configService.Current.TransferBarsShowText ? ImGui.CalcTextSize(maxDlText) : new Vector2(10, 10); + var textSize = _configService.Current.TransferBarsShowText + ? ImGui.CalcTextSize(maxDlText) + : new Vector2(10, 10); - int dlBarHeight = _configService.Current.TransferBarsHeight > ((int)textSize.Y + 5) ? _configService.Current.TransferBarsHeight : (int)textSize.Y + 5; - int dlBarWidth = _configService.Current.TransferBarsWidth > ((int)textSize.X + 10) ? _configService.Current.TransferBarsWidth : (int)textSize.X + 10; + int dlBarHeight = _configService.Current.TransferBarsHeight > ((int)textSize.Y + 5) + ? _configService.Current.TransferBarsHeight + : (int)textSize.Y + 5; + int dlBarWidth = _configService.Current.TransferBarsWidth > ((int)textSize.X + 10) + ? _configService.Current.TransferBarsWidth + : (int)textSize.X + 10; var dlBarStart = new Vector2(screenPos.X - dlBarWidth / 2f, screenPos.Y - dlBarHeight / 2f); var dlBarEnd = new Vector2(screenPos.X + dlBarWidth / 2f, screenPos.Y + dlBarHeight / 2f); + + // Precompute rects + var outerStart = new Vector2(dlBarStart.X - dlBarBorder - 1, dlBarStart.Y - dlBarBorder - 1); + var outerEnd = new Vector2(dlBarEnd.X + dlBarBorder + 1, dlBarEnd.Y + dlBarBorder + 1); + var borderStart = new Vector2(dlBarStart.X - dlBarBorder, dlBarStart.Y - dlBarBorder); + var borderEnd = new Vector2(dlBarEnd.X + dlBarBorder, dlBarEnd.Y + dlBarBorder); + var drawList = ImGui.GetBackgroundDrawList(); - drawList.AddRectFilled( - dlBarStart with { X = dlBarStart.X - dlBarBorder - 1, Y = dlBarStart.Y - dlBarBorder - 1 }, - dlBarEnd with { X = dlBarEnd.X + dlBarBorder + 1, Y = dlBarEnd.Y + dlBarBorder + 1 }, - UiSharedService.Color(0, 0, 0, transparency), 1); - drawList.AddRectFilled(dlBarStart with { X = dlBarStart.X - dlBarBorder, Y = dlBarStart.Y - dlBarBorder }, - dlBarEnd with { X = dlBarEnd.X + dlBarBorder, Y = dlBarEnd.Y + dlBarBorder }, - UiSharedService.Color(220, 220, 220, transparency), 1); - drawList.AddRectFilled(dlBarStart, dlBarEnd, - UiSharedService.Color(0, 0, 0, transparency), 1); + + //Shadow, background, border, bar background + drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, transparency / 2), rounding + 2); + drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, transparency), rounding + 2); + drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(220, 220, 220, transparency), rounding); + drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, transparency), rounding); + var dlProgressPercent = transferredBytes / (double)totalBytes; - drawList.AddRectFilled(dlBarStart, - dlBarEnd with { X = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth) }, - UiSharedService.Color(UIColors.Get("LightlessPurple"))); + var progressEndX = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth); + var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); + + drawList.AddRectFilled( + dlBarStart, + progressEnd, + UiSharedService.Color(UIColors.Get("LightlessPurple")), + rounding + ); if (_configService.Current.TransferBarsShowText) { var downloadText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; - UiSharedService.DrawOutlinedFont(drawList, downloadText, + UiSharedService.DrawOutlinedFont( + drawList, + downloadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(255, 255, 255, transparency), - UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.Color(0, 0, 0, transparency), + 1 + ); } } @@ -269,20 +236,203 @@ public class DownloadUi : WindowMediatorSubscriberBase var textSize = ImGui.CalcTextSize(uploadText); var drawList = ImGui.GetBackgroundDrawList(); - UiSharedService.DrawOutlinedFont(drawList, uploadText, + UiSharedService.DrawOutlinedFont( + drawList, + uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(255, 255, 0, transparency), - UiSharedService.Color(0, 0, 0, transparency), 2); + UiSharedService.Color(0, 0, 0, transparency), + 2 + ); } catch { - _logger.LogDebug("Error drawing upload progress"); + _logger.LogDebug("Error drawing upload progress"); } } } } } + private void DrawDownloadSummaryBox() + { + if (!_currentDownloads.Any()) + return; + + const int transparency = 150; + const float padding = 6f; + const float spacingY = 2f; + const float minBoxWidth = 320f; + + var now = ImGui.GetTime(); + + int totalFiles = 0; + int transferredFiles = 0; + long totalBytes = 0; + long transferredBytes = 0; + + var perPlayer = new List<(string Name, int TransferredFiles, int TotalFiles, long TransferredBytes, long TotalBytes, double SpeedBytesPerSecond)>(); + + foreach (var transfer in _currentDownloads.ToList()) + { + var handler = transfer.Key; + var statuses = transfer.Value.Values; + + var playerTotalFiles = statuses.Sum(s => s.TotalFiles); + var playerTransferredFiles = statuses.Sum(s => s.TransferredFiles); + var playerTotalBytes = statuses.Sum(s => s.TotalBytes); + var playerTransferredBytes = statuses.Sum(s => s.TransferredBytes); + + totalFiles += playerTotalFiles; + transferredFiles += playerTransferredFiles; + totalBytes += playerTotalBytes; + transferredBytes += playerTransferredBytes; + + double speed = 0; + if (playerTotalBytes > 0) + { + if (!_downloadSpeeds.TryGetValue(handler, out var tracker)) + { + tracker = new DownloadSpeedTracker + { + LastBytes = playerTransferredBytes, + LastTime = now, + SpeedBytesPerSecond = 0 + }; + _downloadSpeeds[handler] = tracker; + } + + var dt = now - tracker.LastTime; + var dBytes = playerTransferredBytes - tracker.LastBytes; + + if (dt > 0.1 && dBytes >= 0) + { + var instant = dBytes / dt; + tracker.SpeedBytesPerSecond = tracker.SpeedBytesPerSecond <= 0 + ? instant + : tracker.SpeedBytesPerSecond * 0.8 + instant * 0.2; + } + + tracker.LastTime = now; + tracker.LastBytes = playerTransferredBytes; + speed = tracker.SpeedBytesPerSecond; + } + + perPlayer.Add(( + handler.Name, + playerTransferredFiles, + playerTotalFiles, + playerTransferredBytes, + playerTotalBytes, + speed + )); + } + + foreach (var handler in _downloadSpeeds.Keys.ToList()) + { + if (!_currentDownloads.ContainsKey(handler)) + _downloadSpeeds.Remove(handler); + } + + if (totalFiles == 0 || totalBytes == 0) + return; + + var drawList = ImGui.GetBackgroundDrawList(); + var windowPos = ImGui.GetWindowPos(); + + var headerText = $"Downloading {transferredFiles}/{totalFiles} files"; + var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; + + var totalSpeed = perPlayer.Sum(p => p.SpeedBytesPerSecond); + var speedText = totalSpeed > 0 + ? $"{UiSharedService.ByteToString((long)totalSpeed)}/s" + : "Calculating lightspeed..."; + + var headerSize = ImGui.CalcTextSize(headerText); + var bytesSize = ImGui.CalcTextSize(bytesText); + var speedSize = ImGui.CalcTextSize(speedText); + + float contentWidth = headerSize.X; + if (bytesSize.X > contentWidth) contentWidth = bytesSize.X; + if (speedSize.X > contentWidth) contentWidth = speedSize.X; + + foreach (var p in perPlayer) + { + var playerSpeedText = p.SpeedBytesPerSecond > 0 + ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" + : "-"; + + var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + + $"@ {playerSpeedText}"; + + var lineSize = ImGui.CalcTextSize(line); + if (lineSize.X > contentWidth) + contentWidth = lineSize.X; + } + + var boxWidth = contentWidth + padding * 2; + if (boxWidth < minBoxWidth) + boxWidth = minBoxWidth; + + var lineHeight = ImGui.GetTextLineHeight(); + var numTextLines = 3 + perPlayer.Count; + var barHeight = lineHeight * 0.8f; + var boxHeight = padding * 3 + barHeight + numTextLines * (lineHeight + spacingY); + + var origin = windowPos; + + var boxMin = origin; + var boxMax = origin + new Vector2(boxWidth, boxHeight); + + drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, transparency), 5f); + drawList.AddRect(boxMin, boxMax, UiSharedService.Color(220, 220, 220, transparency), 5f); + + // Progress bar + var cursor = boxMin + new Vector2(padding, padding); + var barMin = cursor; + var barMax = new Vector2(boxMin.X + boxWidth - padding, cursor.Y + barHeight); + + var progress = (float)transferredBytes / totalBytes; + drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, transparency), 3f); + drawList.AddRectFilled( + barMin, + new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), + UiSharedService.Color(UIColors.Get("LightlessPurple")), + 3f + ); + + cursor.Y = barMax.Y + padding; + + // Header + UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + cursor.Y += lineHeight + spacingY; + + // Bytes + UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + cursor.Y += lineHeight + spacingY; + + // Total speed WIP + UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(200, 255, 200, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + cursor.Y += lineHeight * 1.4f; + + // Per-player lines + foreach (var p in perPlayer.OrderByDescending(p => p.TotalBytes)) + { + var playerSpeedText = p.SpeedBytesPerSecond > 0 + ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" + : "-"; + + var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + + $"@ {playerSpeedText}"; + + UiSharedService.DrawOutlinedFont(drawList, line, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + + cursor.Y += lineHeight + spacingY; + } + } + public override bool DrawConditions() { if (_uiShared.EditTrackerPosition) return true; diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 5bff130..834265f 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -19,6 +19,7 @@ using System.Text; using LightlessSync.UI.Services; using LightlessSync.PlayerData.Pairs; using static LightlessSync.Services.PairRequestService; +using LightlessSync.Services.LightFinder; namespace LightlessSync.UI; @@ -35,8 +36,8 @@ public sealed class DtrEntry : IDisposable, IHostedService private readonly Lazy _statusEntry; private readonly Lazy _lightfinderEntry; private readonly ILogger _logger; - private readonly BroadcastService _broadcastService; - private readonly BroadcastScannerService _broadcastScannerService; + private readonly LightFinderService _broadcastService; + private readonly LightFinderScannerService _broadcastScannerService; private readonly LightlessMediator _lightlessMediator; private readonly PairUiService _pairUiService; private readonly PairRequestService _pairRequestService; @@ -62,8 +63,8 @@ public sealed class DtrEntry : IDisposable, IHostedService PairRequestService pairRequestService, ApiController apiController, ServerConfigurationManager serverManager, - BroadcastService broadcastService, - BroadcastScannerService broadcastScannerService, + LightFinderService broadcastService, + LightFinderScannerService broadcastScannerService, DalamudUtilService dalamudUtilService) { _logger = logger; diff --git a/LightlessSync/UI/BroadcastUI.cs b/LightlessSync/UI/LightFinderUI.cs similarity index 97% rename from LightlessSync/UI/BroadcastUI.cs rename to LightlessSync/UI/LightFinderUI.cs index 1efb1fa..9f118a3 100644 --- a/LightlessSync/UI/BroadcastUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -7,6 +7,7 @@ using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; using LightlessSync.Services; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; @@ -15,28 +16,28 @@ using System.Numerics; namespace LightlessSync.UI { - public class BroadcastUI : WindowMediatorSubscriberBase + public class LightFinderUI : WindowMediatorSubscriberBase { private readonly ApiController _apiController; private readonly LightlessConfigService _configService; - private readonly BroadcastService _broadcastService; + private readonly LightFinderService _broadcastService; private readonly UiSharedService _uiSharedService; - private readonly BroadcastScannerService _broadcastScannerService; + private readonly LightFinderScannerService _broadcastScannerService; private IReadOnlyList _allSyncshells = Array.Empty(); private string _userUid = string.Empty; private readonly List<(string Label, string? GID, bool IsAvailable)> _syncshellOptions = new(); - public BroadcastUI( - ILogger logger, + public LightFinderUI( + ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollectorService, - BroadcastService broadcastService, + LightFinderService broadcastService, LightlessConfigService configService, UiSharedService uiShared, ApiController apiController, - BroadcastScannerService broadcastScannerService + LightFinderScannerService broadcastScannerService ) : base(logger, mediator, "Lightfinder###LightlessLightfinderUI", performanceCollectorService) { _broadcastService = broadcastService; diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index c0f0a68..1820ed0 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -38,6 +38,7 @@ using System.Net.Http.Json; using System.Numerics; using System.Text; using System.Text.Json; +using LightlessSync.Services.PairProcessing; namespace LightlessSync.UI; diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 4d4dca7..5d67544 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -15,15 +15,16 @@ using LightlessSync.WebAPI; using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; using System.Numerics; +using LightlessSync.Services.LightFinder; namespace LightlessSync.UI; public class SyncshellFinderUI : WindowMediatorSubscriberBase { private readonly ApiController _apiController; - private readonly BroadcastService _broadcastService; + private readonly LightFinderService _broadcastService; private readonly UiSharedService _uiSharedService; - private readonly BroadcastScannerService _broadcastScannerService; + private readonly LightFinderScannerService _broadcastScannerService; private readonly PairUiService _pairUiService; private readonly DalamudUtilService _dalamudUtilService; @@ -44,10 +45,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollectorService, - BroadcastService broadcastService, + LightFinderService broadcastService, UiSharedService uiShared, ApiController apiController, - BroadcastScannerService broadcastScannerService, + LightFinderScannerService broadcastScannerService, PairUiService pairUiService, DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) { @@ -111,7 +112,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase if (ImGui.Button("Open Lightfinder", new Vector2(200 * ImGuiHelpers.GlobalScale, 0))) { - Mediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } ImGui.PopStyleColor(); diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 8562595..dabe8c0 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -781,7 +781,7 @@ public class TopTabMenu if (_uiSharedService.IconTextButton(FontAwesomeIcon.PersonCirclePlus, "Lightfinder", buttonX, center: true)) { - _lightlessMediator.Publish(new UiToggleMessage(typeof(BroadcastUI))); + _lightlessMediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } ImGui.SameLine(); diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 94df4fa..181b02a 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -4,7 +4,6 @@ using LightlessSync.API.Dto.Files; using LightlessSync.API.Routes; using LightlessSync.FileCache; using LightlessSync.PlayerData.Handlers; -using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files.Models; @@ -13,6 +12,7 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.PairProcessing; namespace LightlessSync.WebAPI.Files; -- 2.49.1 From e0e2304253d2f2d6e205874f7afe627bd84f6379 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 08:10:15 +0100 Subject: [PATCH 061/140] Removed usings --- LightlessSync/UI/DownloadUi.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 6d6d7dd..18df160 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,7 +1,5 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; -using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; -- 2.49.1 From 91393bf4a10d0d87c495e69ea67676ca120f4393 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 30 Nov 2025 19:59:37 +0900 Subject: [PATCH 062/140] added chat report functionality and some other random stuff --- .../FileCache/TransientResourceManager.cs | 24 +- .../Interop/Ipc/Framework/IpcFramework.cs | 11 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 2 +- .../Interop/Ipc/Penumbra/PenumbraRedraw.cs | 12 +- .../Configurations/TransientConfig.cs | 58 ++-- LightlessSync/Services/Chat/ChatModels.cs | 2 + .../Services/Chat/ZoneChatService.cs | 61 +++++ LightlessSync/UI/LightlessNotificationUI.cs | 14 +- LightlessSync/UI/UISharedService.cs | 36 ++- LightlessSync/UI/ZoneChatUi.cs | 250 ++++++++++++++++++ 10 files changed, 417 insertions(+), 53 deletions(-) diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index f808fa6..7f982a3 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -142,12 +142,21 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase return; } - var transientResources = resources.ToList(); - Logger.LogDebug("Persisting {count} transient resources", transientResources.Count); - List newlyAddedGamePaths = resources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); - foreach (var gamePath in transientResources) + List transientResources; + lock (resources) { - semiTransientResources.Add(gamePath); + transientResources = resources.ToList(); + } + + Logger.LogDebug("Persisting {count} transient resources", transientResources.Count); + List newlyAddedGamePaths; + lock (semiTransientResources) + { + newlyAddedGamePaths = transientResources.Except(semiTransientResources, StringComparer.Ordinal).ToList(); + foreach (var gamePath in transientResources) + { + semiTransientResources.Add(gamePath); + } } bool saveConfig = false; @@ -180,7 +189,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _configurationService.Save(); } - TransientResources[objectKind].Clear(); + lock (resources) + { + resources.Clear(); + } } public void RemoveTransientResource(ObjectKind objectKind, string path) diff --git a/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs index dbf1c15..a68367a 100644 --- a/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs +++ b/LightlessSync/Interop/Ipc/Framework/IpcFramework.cs @@ -1,6 +1,7 @@ using Dalamud.Plugin; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; +using System.Linq; namespace LightlessSync.Interop.Ipc.Framework; @@ -107,7 +108,9 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer try { var plugin = PluginInterface.InstalledPlugins - .FirstOrDefault(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase)); + .Where(p => string.Equals(p.InternalName, Descriptor.InternalName, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(p => p.IsLoaded) + .FirstOrDefault(); if (plugin == null) { @@ -119,7 +122,7 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer return IpcConnectionState.VersionMismatch; } - if (!IsPluginEnabled()) + if (!IsPluginEnabled(plugin)) { return IpcConnectionState.PluginDisabled; } @@ -138,8 +141,8 @@ public abstract class IpcServiceBase : DisposableMediatorSubscriberBase, IIpcSer } } - protected virtual bool IsPluginEnabled() - => true; + protected virtual bool IsPluginEnabled(IExposedPlugin plugin) + => plugin.IsLoaded; protected virtual bool IsPluginReady() => true; diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index 4169e5c..c167654 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -122,7 +122,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase } } - protected override bool IsPluginEnabled() + protected override bool IsPluginEnabled(IExposedPlugin plugin) { try { diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs index 7b3abd1..5d47d3a 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraRedraw.cs @@ -51,9 +51,14 @@ public sealed class PenumbraRedraw : PenumbraBase return; } + var redrawSemaphore = _redrawManager.RedrawSemaphore; + var semaphoreAcquired = false; + try { - await _redrawManager.RedrawSemaphore.WaitAsync(token).ConfigureAwait(false); + await redrawSemaphore.WaitAsync(token).ConfigureAwait(false); + semaphoreAcquired = true; + await _redrawManager.PenumbraRedrawInternalAsync(logger, handler, applicationId, chara => { logger.LogDebug("[{ApplicationId}] Calling on IPC: PenumbraRedraw", applicationId); @@ -62,7 +67,10 @@ public sealed class PenumbraRedraw : PenumbraBase } finally { - _redrawManager.RedrawSemaphore.Release(); + if (semaphoreAcquired) + { + redrawSemaphore.Release(); + } } } diff --git a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs index c9a5f74..0bcb5ad 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/TransientConfig.cs @@ -13,6 +13,8 @@ public class TransientConfig : ILightlessConfiguration public Dictionary> JobSpecificCache { get; set; } = []; public Dictionary> JobSpecificPetCache { get; set; } = []; + private readonly object _cacheLock = new(); + public TransientPlayerConfig() { @@ -39,45 +41,51 @@ public class TransientConfig : ILightlessConfiguration public int RemovePath(string gamePath, ObjectKind objectKind) { - int removedEntries = 0; - if (objectKind == ObjectKind.Player) + lock (_cacheLock) { - if (GlobalPersistentCache.Remove(gamePath)) removedEntries++; - foreach (var kvp in JobSpecificCache) + int removedEntries = 0; + if (objectKind == ObjectKind.Player) { - if (kvp.Value.Remove(gamePath)) removedEntries++; + if (GlobalPersistentCache.Remove(gamePath)) removedEntries++; + foreach (var kvp in JobSpecificCache) + { + if (kvp.Value.Remove(gamePath)) removedEntries++; + } } - } - if (objectKind == ObjectKind.Pet) - { - foreach (var kvp in JobSpecificPetCache) + if (objectKind == ObjectKind.Pet) { - if (kvp.Value.Remove(gamePath)) removedEntries++; + foreach (var kvp in JobSpecificPetCache) + { + if (kvp.Value.Remove(gamePath)) removedEntries++; + } } + return removedEntries; } - return removedEntries; } public void AddOrElevate(uint jobId, string gamePath) { - // check if it's in the global cache, if yes, do nothing - if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal)) + lock (_cacheLock) { - return; - } + // check if it's in the global cache, if yes, do nothing + if (GlobalPersistentCache.Contains(gamePath, StringComparer.Ordinal)) + { + return; + } - if (ElevateIfNeeded(jobId, gamePath)) return; + if (ElevateIfNeeded(jobId, gamePath)) return; - // check if the jobid is already in the cache to start - if (!JobSpecificCache.TryGetValue(jobId, out var jobCache)) - { - JobSpecificCache[jobId] = jobCache = new(); - } + // check if the jobid is already in the cache to start + if (!JobSpecificCache.TryGetValue(jobId, out var jobCache)) + { + JobSpecificCache[jobId] = jobCache = new(); + } - // check if the path is already in the job specific cache - if (!jobCache.Contains(gamePath, StringComparer.Ordinal)) - { - jobCache.Add(gamePath); + // check if the path is already in the job specific cache + if (!jobCache.Contains(gamePath, StringComparer.Ordinal)) + { + jobCache.Add(gamePath); + } } } } diff --git a/LightlessSync/Services/Chat/ChatModels.cs b/LightlessSync/Services/Chat/ChatModels.cs index e9058e7..ba89084 100644 --- a/LightlessSync/Services/Chat/ChatModels.cs +++ b/LightlessSync/Services/Chat/ChatModels.cs @@ -19,3 +19,5 @@ public readonly record struct ChatChannelSnapshot( bool HasUnread, int UnreadCount, IReadOnlyList Messages); + +public readonly record struct ChatReportResult(bool Success, string? ErrorMessage); \ No newline at end of file diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 4499cf8..9126436 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -17,6 +17,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private const int MaxUnreadCount = 999; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneChannelKey = "zone"; + private const int MaxReportReasonLength = 500; + private const int MaxReportContextLength = 1000; private readonly ApiController _apiController; private readonly ChatConfigService _chatConfigService; @@ -244,6 +246,65 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS public Task ResolveParticipantAsync(ChatChannelDescriptor descriptor, string token) => _apiController.ResolveChatParticipant(new ChatParticipantResolveRequestDto(descriptor, token)); + public Task ReportMessageAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) + { + if (string.IsNullOrWhiteSpace(messageId)) + { + return Task.FromResult(new ChatReportResult(false, "Unable to locate the selected message.")); + } + + var trimmedReason = reason?.Trim() ?? string.Empty; + if (trimmedReason.Length == 0) + { + return Task.FromResult(new ChatReportResult(false, "Please describe why you are reporting this message.")); + } + + lock (_sync) + { + if (!_chatEnabled) + { + return Task.FromResult(new ChatReportResult(false, "Enable chat before reporting messages.")); + } + + if (!_isConnected) + { + return Task.FromResult(new ChatReportResult(false, "Connect to the chat server before reporting messages.")); + } + } + + if (trimmedReason.Length > MaxReportReasonLength) + { + trimmedReason = trimmedReason[..MaxReportReasonLength]; + } + + string? context = null; + if (!string.IsNullOrWhiteSpace(additionalContext)) + { + context = additionalContext.Trim(); + if (context.Length > MaxReportContextLength) + { + context = context[..MaxReportContextLength]; + } + } + + var normalizedDescriptor = descriptor.WithNormalizedCustomKey(); + return ReportMessageInternalAsync(normalizedDescriptor, messageId.Trim(), trimmedReason, context); + } + + private async Task ReportMessageInternalAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) + { + try + { + await _apiController.ReportChatMessage(new ChatReportSubmitDto(descriptor, messageId, reason, additionalContext)).ConfigureAwait(false); + return new ChatReportResult(true, null); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to submit chat report"); + return new ChatReportResult(false, "Failed to submit report. Please try again."); + } + } + public Task StartAsync(CancellationToken cancellationToken) { Mediator.Subscribe(this, _ => HandleLogin()); diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index bdbe8df..b280350 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -73,7 +73,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase else { _notifications.Add(notification); - _logger.LogDebug("Added new notification: {Title}", notification.Title); + _logger.LogTrace("Added new notification: {Title}", notification.Title); } if (!IsOpen) IsOpen = true; @@ -93,7 +93,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase existing.CreatedAt = DateTime.UtcNow; } - _logger.LogDebug("Updated existing notification: {Title}", updated.Title); + _logger.LogTrace("Updated existing notification: {Title}", updated.Title); } public void RemoveNotification(string id) @@ -576,7 +576,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase { var buttonWidth = CalculateActionButtonWidth(notification.Actions.Count, availableWidth); - _logger.LogDebug("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", + _logger.LogTrace("Drawing {ActionCount} notification actions, buttonWidth: {ButtonWidth}, availableWidth: {AvailableWidth}", notification.Actions.Count, buttonWidth, availableWidth); var startX = ImGui.GetCursorPosX(); @@ -606,7 +606,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private void DrawActionButton(LightlessNotificationAction action, LightlessNotification notification, float alpha, float buttonWidth) { - _logger.LogDebug("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); + _logger.LogTrace("Drawing action button: {ActionId} - {ActionLabel}, width: {ButtonWidth}", action.Id, action.Label, buttonWidth); var buttonColor = action.Color; buttonColor.W *= alpha; @@ -633,15 +633,15 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase buttonPressed = ImGui.Button(action.Label, new Vector2(buttonWidth, 0)); } - _logger.LogDebug("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed); + _logger.LogTrace("Button {ActionId} pressed: {ButtonPressed}", action.Id, buttonPressed); if (buttonPressed) { try { - _logger.LogDebug("Executing action: {ActionId}", action.Id); + _logger.LogTrace("Executing action: {ActionId}", action.Id); action.OnClick(notification); - _logger.LogDebug("Action executed successfully: {ActionId}", action.Id); + _logger.LogTrace("Action executed successfully: {ActionId}", action.Id); } catch (Exception ex) { diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 2875acb..537d1bf 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -15,6 +15,7 @@ using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; +using LightlessSync.Interop.Ipc.Framework; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Localization; @@ -975,36 +976,36 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase ImGui.SameLine(150); ColorText("Penumbra", GetBoolColor(_penumbraExists)); - AttachToolTip($"Penumbra is " + (_penumbraExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Penumbra", _penumbraExists, _ipcManager.Penumbra.State)); ImGui.SameLine(); ColorText("Glamourer", GetBoolColor(_glamourerExists)); - AttachToolTip($"Glamourer is " + (_glamourerExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Glamourer", _glamourerExists, _ipcManager.Glamourer.State)); ImGui.TextUnformatted("Optional Plugins:"); ImGui.SameLine(150); ColorText("SimpleHeels", GetBoolColor(_heelsExists)); - AttachToolTip($"SimpleHeels is " + (_heelsExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("SimpleHeels", _heelsExists, _ipcManager.Heels.State)); ImGui.SameLine(); ColorText("Customize+", GetBoolColor(_customizePlusExists)); - AttachToolTip($"Customize+ is " + (_customizePlusExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Customize+", _customizePlusExists, _ipcManager.CustomizePlus.State)); ImGui.SameLine(); ColorText("Honorific", GetBoolColor(_honorificExists)); - AttachToolTip($"Honorific is " + (_honorificExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Honorific", _honorificExists, _ipcManager.Honorific.State)); ImGui.SameLine(); ColorText("Moodles", GetBoolColor(_moodlesExists)); - AttachToolTip($"Moodles is " + (_moodlesExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Moodles", _moodlesExists, _ipcManager.Moodles.State)); ImGui.SameLine(); ColorText("PetNicknames", GetBoolColor(_petNamesExists)); - AttachToolTip($"PetNicknames is " + (_petNamesExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("PetNicknames", _petNamesExists, _ipcManager.PetNames.State)); ImGui.SameLine(); ColorText("Brio", GetBoolColor(_brioExists)); - AttachToolTip($"Brio is " + (_brioExists ? "available and up to date." : "unavailable or not up to date.")); + AttachToolTip(BuildPluginTooltip("Brio", _brioExists, _ipcManager.Brio.State)); if (!_penumbraExists || !_glamourerExists) { @@ -1015,6 +1016,25 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return true; } + private static string BuildPluginTooltip(string pluginName, bool isAvailable, IpcConnectionState state) + { + var availability = isAvailable ? "available and up to date." : "unavailable or not up to date."; + return $"{pluginName} is {availability}{Environment.NewLine}IPC State: {DescribeIpcState(state)}"; + } + + private static string DescribeIpcState(IpcConnectionState state) + => state switch + { + IpcConnectionState.Unknown => "Not evaluated yet", + IpcConnectionState.MissingPlugin => "Plugin not installed", + IpcConnectionState.VersionMismatch => "Installed version below required minimum", + IpcConnectionState.PluginDisabled => "Plugin installed but disabled", + IpcConnectionState.NotReady => "Plugin is not ready yet", + IpcConnectionState.Available => "Available", + IpcConnectionState.Error => "Error occurred while checking IPC", + _ => state.ToString() + }; + public int DrawServiceSelection(bool selectOnChange = false, bool showConnect = true) { string[] comboEntries = _serverConfigurationManager.GetServerNames(); diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index d8ac877..c2fcf02 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -29,9 +29,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { private const string ChatDisabledStatus = "Chat services disabled"; private const string SettingsPopupId = "zone_chat_settings_popup"; + private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const float DefaultWindowOpacity = .97f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; + private const int ReportReasonMaxLength = 500; + private const int ReportContextMaxLength = 1000; private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; @@ -50,6 +53,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private float? _pendingChannelScroll; private float _channelScroll; private float _channelScrollMax; + private ChatChannelSnapshot? _reportTargetChannel; + private ChatMessageEntry? _reportTargetMessage; + private string _reportReason = string.Empty; + private string _reportAdditionalContext = string.Empty; + private bool _reportPopupOpen; + private bool _reportPopupRequested; + private bool _reportSubmitting; + private string? _reportError; + private ChatReportResult? _reportSubmissionResult; public ZoneChatUi( ILogger logger, @@ -112,6 +124,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase DrawConnectionControls(); var channels = _zoneChatService.GetChannelsSnapshot(); + DrawReportPopup(); if (channels.Count == 0) { @@ -482,6 +495,221 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PopStyleVar(); } + private void DrawReportPopup() + { + if (!_reportPopupOpen) + return; + + var desiredPopupSize = new Vector2(520f * ImGuiHelpers.GlobalScale, 0f); + ImGui.SetNextWindowSize(desiredPopupSize, ImGuiCond.Always); + if (_reportPopupRequested) + { + ImGui.OpenPopup(ReportPopupId); + _reportPopupRequested = false; + } + else if (!ImGui.IsPopupOpen(ReportPopupId, ImGuiPopupFlags.AnyPopupLevel)) + { + ImGui.OpenPopup(ReportPopupId); + } + + var popupFlags = UiSharedService.PopupWindowFlags | ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoSavedSettings; + if (!ImGui.BeginPopupModal(ReportPopupId, popupFlags)) + return; + + if (_reportTargetChannel is not { } channel || _reportTargetMessage is not { } message) + { + CloseReportPopup(); + ImGui.EndPopup(); + return; + } + + if (_reportSubmissionResult is { } pendingResult) + { + _reportSubmissionResult = null; + _reportSubmitting = false; + + if (pendingResult.Success) + { + Mediator.Publish(new NotificationMessage("Zone Chat", "Report submitted for moderator review.", NotificationType.Info, TimeSpan.FromSeconds(3))); + CloseReportPopup(); + ImGui.EndPopup(); + return; + } + + _reportError = pendingResult.ErrorMessage ?? "Failed to submit report. Please try again."; + } + + var channelPrefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; + var channelLabel = $"{channelPrefix}: {channel.DisplayName}"; + if (channel.Type == ChatChannelType.Zone && channel.Descriptor.WorldId != 0) + { + channelLabel += $" (World #{channel.Descriptor.WorldId})"; + } + + ImGui.TextUnformatted(channelLabel); + ImGui.TextUnformatted($"Sender: {message.DisplayName}"); + ImGui.TextUnformatted($"Sent: {message.Payload.SentAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"); + + ImGui.Separator(); + ImGui.PushTextWrapPos(ImGui.GetWindowContentRegionMax().X); + ImGui.TextWrapped(message.Payload.Message); + ImGui.PopTextWrapPos(); + ImGui.Separator(); + + ImGui.TextUnformatted("Reason (required)"); + if (ImGui.InputTextMultiline("##chat_report_reason", ref _reportReason, ReportReasonMaxLength, new Vector2(-1, 80f * ImGuiHelpers.GlobalScale))) + { + if (_reportReason.Length > ReportReasonMaxLength) + { + _reportReason = _reportReason[..(int)ReportReasonMaxLength]; + } + } + + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"{_reportReason.Length}/{ReportReasonMaxLength}"); + ImGui.PopStyleColor(); + + ImGui.Spacing(); + ImGui.TextUnformatted("Additional context (optional)"); + if (ImGui.InputTextMultiline("##chat_report_context", ref _reportAdditionalContext, ReportContextMaxLength, new Vector2(-1, 120f * ImGuiHelpers.GlobalScale))) + { + if (_reportAdditionalContext.Length > ReportContextMaxLength) + { + _reportAdditionalContext = _reportAdditionalContext[..(int)ReportContextMaxLength]; + } + } + + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"{_reportAdditionalContext.Length}/{ReportContextMaxLength}"); + ImGui.PopStyleColor(); + + if (!string.IsNullOrEmpty(_reportError)) + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("DimRed")); + ImGui.TextWrapped(_reportError); + ImGui.PopStyleColor(); + } + + if (_reportSubmitting) + { + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted("Submitting report..."); + ImGui.PopStyleColor(); + } + + ImGui.Separator(); + var style = ImGui.GetStyle(); + var availableWidth = Math.Max(0f, ImGui.GetContentRegionAvail().X); + var buttonWidth = Math.Max(100f * ImGuiHelpers.GlobalScale, (availableWidth - style.ItemSpacing.X) / 2f); + var canSubmit = !_reportSubmitting && _reportReason.Trim().Length > 0; + + using (ImRaii.Disabled(!canSubmit)) + { + if (ImGui.Button("Submit Report", new Vector2(buttonWidth, 0f)) && canSubmit) + { + BeginReportSubmission(channel, message); + } + } + + ImGui.SameLine(); + if (ImGui.Button("Cancel", new Vector2(buttonWidth, 0f))) + { + CloseReportPopup(); + } + + ImGui.EndPopup(); + } + + private void OpenReportPopup(ChatChannelSnapshot channel, ChatMessageEntry message) + { + _reportTargetChannel = channel; + _reportTargetMessage = message; + _logger.LogDebug("Opening report popup for channel {ChannelKey}, message {MessageId}", channel.Key, message.Payload.MessageId); + _reportReason = string.Empty; + _reportAdditionalContext = string.Empty; + _reportError = null; + _reportSubmissionResult = null; + _reportSubmitting = false; + _reportPopupOpen = true; + _reportPopupRequested = true; + } + + private void BeginReportSubmission(ChatChannelSnapshot channel, ChatMessageEntry message) + { + if (_reportSubmitting) + return; + + var trimmedReason = _reportReason.Trim(); + if (trimmedReason.Length == 0) + { + _reportError = "Please describe the issue before submitting."; + return; + } + + var trimmedContext = string.IsNullOrWhiteSpace(_reportAdditionalContext) + ? null + : _reportAdditionalContext.Trim(); + + _reportSubmitting = true; + _reportError = null; + _reportSubmissionResult = null; + + var descriptor = channel.Descriptor; + var messageId = message.Payload.MessageId; + if (string.IsNullOrWhiteSpace(messageId)) + { + _reportSubmitting = false; + _reportError = "Unable to report this message."; + _logger.LogWarning("Report submission aborted: missing message id for channel {ChannelKey}", channel.Key); + return; + } + + _logger.LogDebug("Submitting chat report for channel {ChannelKey}, message {MessageId}", channel.Key, messageId); + _ = Task.Run(async () => + { + ChatReportResult result; + try + { + result = await _zoneChatService.ReportMessageAsync(descriptor, messageId, trimmedReason, trimmedContext).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to submit chat report"); + result = new ChatReportResult(false, "Failed to submit report. Please try again."); + } + + _reportSubmissionResult = result; + if (result.Success) + { + _logger.LogInformation("Chat report submitted successfully for channel {ChannelKey}, message {MessageId}", channel.Key, messageId); + } + else + { + _logger.LogWarning("Chat report submission failed for channel {ChannelKey}, message {MessageId}: {Error}", channel.Key, messageId, result.ErrorMessage); + } + }); + } + + private void CloseReportPopup() + { + _reportPopupOpen = false; + _reportPopupRequested = false; + ResetReportPopupState(); + ImGui.CloseCurrentPopup(); + } + + private void ResetReportPopupState() + { + _reportTargetChannel = null; + _reportTargetMessage = null; + _reportReason = string.Empty; + _reportAdditionalContext = string.Empty; + _reportError = null; + _reportSubmissionResult = null; + _reportSubmitting = false; + _reportPopupRequested = false; + } + private bool TrySendDraft(ChatChannelSnapshot channel, string draft) { var trimmed = draft.Trim(); @@ -513,6 +741,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { yield return viewProfile; } + + if (TryCreateReportMessageAction(channel, message, out var reportAction)) + { + yield return reportAction; + } } private bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) @@ -578,6 +811,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + private bool TryCreateReportMessageAction(ChatChannelSnapshot channel, ChatMessageEntry message, out ChatMessageContextAction action) + { + action = default; + if (message.FromSelf) + return false; + + if (string.IsNullOrWhiteSpace(message.Payload.MessageId)) + return false; + + action = new ChatMessageContextAction( + "Report Message", + true, + () => OpenReportPopup(channel, message)); + + return true; + } + private Task OpenStandardProfileAsync(UserData user) { _profileManager.GetLightlessProfile(user); -- 2.49.1 From ba5c8b588ec065ab399bee2d619664a5b5a4fece Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 30 Nov 2025 20:32:22 +0900 Subject: [PATCH 063/140] meow timestamp meow --- LightlessSync/UI/ZoneChatUi.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index c2fcf02..c3ed2da 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -259,6 +259,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) { + var contextLocalTimestamp = message.Payload.SentAtUtc.ToLocalTime(); + var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); + ImGui.TextDisabled(contextTimestampText); + ImGui.Separator(); + foreach (var action in GetContextMenuActions(channel, message)) { if (ImGui.MenuItem(action.Label, string.Empty, false, action.IsEnabled)) @@ -456,7 +461,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessBlue"), new SeStringUtils.RichTextEntry("Punishments scale from a permanent chat ban up to a full Lightless account ban."), - new SeStringUtils.RichTextEntry(" (Appeals are NOT possible.) ", UIColors.Get("DimRed"), true)); + new SeStringUtils.RichTextEntry(" (Appeals are possible, but will be accepted only in clear cases of error.) ", UIColors.Get("DimRed"), true)); ImGui.PopTextWrapPos(); } -- 2.49.1 From 8076d63ce277cf4392ee7c9d0feb2549efb75d39 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 30 Nov 2025 21:35:17 +0900 Subject: [PATCH 064/140] add chat message scaling --- .../Configurations/ChatConfig.cs | 1 + LightlessSync/UI/ZoneChatUi.cs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 7812887..9065b81 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -12,4 +12,5 @@ public sealed class ChatConfig : ILightlessConfiguration public float ChatWindowOpacity { get; set; } = .97f; public bool IsWindowPinned { get; set; } = false; public bool AutoOpenChatOnPluginLoad { get; set; } = false; + public float ChatFontScale { get; set; } = 1.0f; } diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index c3ed2da..aeddbbb 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -33,6 +33,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const float DefaultWindowOpacity = .97f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; + private const float MinChatFontScale = 0.75f; + private const float MaxChatFontScale = 1.5f; private const int ReportReasonMaxLength = 500; private const int ReportContextMaxLength = 1000; @@ -215,6 +217,14 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (!child) return; + var configuredFontScale = Math.Clamp(_chatConfigService.Current.ChatFontScale, MinChatFontScale, MaxChatFontScale); + var restoreFontScale = false; + if (Math.Abs(configuredFontScale - 1f) > 0.001f) + { + ImGui.SetWindowFontScale(configuredFontScale); + restoreFontScale = true; + } + var drawList = ImGui.GetWindowDrawList(); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); @@ -284,6 +294,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetScrollHereY(1f); _scrollToBottom = false; } + + if (restoreFontScale) + { + ImGui.SetWindowFontScale(1f); + } } private void DrawInput(ChatChannelSnapshot channel) @@ -1122,6 +1137,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Toggles the timestamp prefix on messages."); } + var fontScale = Math.Clamp(chatConfig.ChatFontScale, MinChatFontScale, MaxChatFontScale); + var fontScaleChanged = ImGui.SliderFloat("Message font scale", ref fontScale, MinChatFontScale, MaxChatFontScale, "%.2fx"); + var resetFontScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetFontScale) + { + fontScale = 1.0f; + fontScaleChanged = true; + } + + if (fontScaleChanged) + { + chatConfig.ChatFontScale = fontScale; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Adjusts the scale of chat message text.\nRight-click to reset."); + } + var windowOpacity = Math.Clamp(chatConfig.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); var opacityChanged = ImGui.SliderFloat("Window transparency", ref windowOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f"); var resetOpacity = ImGui.IsItemClicked(ImGuiMouseButton.Right); -- 2.49.1 From 9d6a0a1257338be28f1558ed268f9a6a28579c37 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 30 Nov 2025 15:55:32 +0100 Subject: [PATCH 065/140] Fixed colors so it uses dalamud or lightless, added notifications system back --- LightlessSync/UI/DownloadUi.cs | 73 ++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 18df160..aff23ba 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,5 +1,7 @@ using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; @@ -103,6 +105,28 @@ public class DownloadUi : WindowMediatorSubscriberBase { if (_configService.Current.ShowTransferWindow) { + var limiterSnapshot = _pairProcessingLimiter.GetSnapshot(); + + // Check if download notifications are enabled (not set to TextOverlay) + var useNotifications = _configService.Current.UseLightlessNotifications + ? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay + : _configService.Current.UseNotificationsForDownloads; + + if (useNotifications) + { + if (!_currentDownloads.IsEmpty) + { + UpdateDownloadNotificationIfChanged(limiterSnapshot); + _notificationDismissed = false; + } + else if (!_notificationDismissed) + { + Mediator.Publish(new LightlessNotificationDismissMessage("pair_download_progress")); + _notificationDismissed = true; + _lastDownloadStateHash = 0; + } + } + DrawDownloadSummaryBox(); if (_configService.Current.ShowUploading) @@ -120,10 +144,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var textSize = ImGui.CalcTextSize(uploadText); var drawList = ImGui.GetBackgroundDrawList(); - UiSharedService.DrawOutlinedFont( - drawList, - uploadText, - screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, + UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(255, 255, 0, transparency), UiSharedService.Color(0, 0, 0, transparency), 2 @@ -155,8 +176,8 @@ public class DownloadUi : WindowMediatorSubscriberBase _smoothed.Remove(transferKey); continue; } - //Smoothing out the movement and fix jitter around the position. + //Smoothing out the movement and fix jitter around the position. Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos : rawPos; @@ -191,28 +212,20 @@ public class DownloadUi : WindowMediatorSubscriberBase //Shadow, background, border, bar background drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, transparency / 2), rounding + 2); drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, transparency), rounding + 2); - drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(220, 220, 220, transparency), rounding); + drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(ImGuiColors.DalamudGrey), rounding); drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, transparency), rounding); var dlProgressPercent = transferredBytes / (double)totalBytes; var progressEndX = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth); var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); - drawList.AddRectFilled( - dlBarStart, - progressEnd, - UiSharedService.Color(UIColors.Get("LightlessPurple")), - rounding - ); + drawList.AddRectFilled(dlBarStart, progressEnd, UiSharedService.Color(UIColors.Get("LightlessPurple")), rounding); if (_configService.Current.TransferBarsShowText) { var downloadText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; - UiSharedService.DrawOutlinedFont( - drawList, - downloadText, - screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, - UiSharedService.Color(255, 255, 255, transparency), + UiSharedService.DrawOutlinedFont(drawList, downloadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, + UiSharedService.Color(ImGuiColors.DalamudGrey), UiSharedService.Color(0, 0, 0, transparency), 1 ); @@ -234,11 +247,8 @@ public class DownloadUi : WindowMediatorSubscriberBase var textSize = ImGui.CalcTextSize(uploadText); var drawList = ImGui.GetBackgroundDrawList(); - UiSharedService.DrawOutlinedFont( - drawList, - uploadText, - screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, - UiSharedService.Color(255, 255, 0, transparency), + UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, + UiSharedService.Color(ImGuiColors.DalamudYellow), UiSharedService.Color(0, 0, 0, transparency), 2 ); @@ -384,7 +394,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var boxMax = origin + new Vector2(boxWidth, boxHeight); drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, transparency), 5f); - drawList.AddRect(boxMin, boxMax, UiSharedService.Color(220, 220, 220, transparency), 5f); + drawList.AddRect(boxMin, boxMax, UiSharedService.Color(ImGuiColors.DalamudGrey), 5f); // Progress bar var cursor = boxMin + new Vector2(padding, padding); @@ -393,25 +403,20 @@ public class DownloadUi : WindowMediatorSubscriberBase var progress = (float)transferredBytes / totalBytes; drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, transparency), 3f); - drawList.AddRectFilled( - barMin, - new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), - UiSharedService.Color(UIColors.Get("LightlessPurple")), - 3f - ); + drawList.AddRectFilled(barMin, new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), UiSharedService.Color(UIColors.Get("LightlessPurple")), 3f); cursor.Y = barMax.Y + padding; // Header - UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; // Bytes - UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; // Total speed WIP - UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(200, 255, 200, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight * 1.4f; // Per-player lines @@ -425,7 +430,7 @@ public class DownloadUi : WindowMediatorSubscriberBase $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + $"@ {playerSpeedText}"; - UiSharedService.DrawOutlinedFont(drawList, line, cursor, UiSharedService.Color(255, 255, 255, transparency), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, line, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); cursor.Y += lineHeight + spacingY; } @@ -435,7 +440,7 @@ public class DownloadUi : WindowMediatorSubscriberBase { if (_uiShared.EditTrackerPosition) return true; if (!_configService.Current.ShowTransferWindow && !_configService.Current.ShowTransferBars) return false; - if (!_currentDownloads.Any() && !_fileTransferManager.IsUploading && !_uploadingPlayers.Any()) return false; + if (_currentDownloads.IsEmpty && !_fileTransferManager.IsUploading && _uploadingPlayers.IsEmpty) return false; if (!IsOpen) return false; return true; } -- 2.49.1 From febc47442a425271727614a9e9ff4a36dc7c07a5 Mon Sep 17 00:00:00 2001 From: azyges Date: Mon, 1 Dec 2025 03:12:00 +0900 Subject: [PATCH 066/140] goober fix tooltips --- LightlessSync/UI/ZoneChatUi.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index aeddbbb..697f100 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1153,7 +1153,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } if (ImGui.IsItemHovered()) { - ImGui.SetTooltip("Adjusts the scale of chat message text.\nRight-click to reset."); + ImGui.SetTooltip("Adjust scale of chat message text.\nRight-click to reset to default."); } var windowOpacity = Math.Clamp(chatConfig.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); @@ -1173,7 +1173,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (ImGui.IsItemHovered()) { - ImGui.SetTooltip("Adjust transparency of the chat window.\nRight-click to reset to default."); + ImGui.SetTooltip("Adjust chat window transparency.\nRight-click to reset to default."); } ImGui.EndPopup(); -- 2.49.1 From a77261a096a27956da6fbaf5bc9bedffe6010098 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 2 Dec 2025 08:44:34 +0900 Subject: [PATCH 067/140] mid settings improvement attempt --- LightlessSync/UI/SettingsUi.cs | 2315 +++++++++++++++------------ LightlessSync/UI/UISharedService.cs | 95 ++ 2 files changed, 1393 insertions(+), 1017 deletions(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1820ed0..fb74b2f 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -79,8 +79,69 @@ public class SettingsUi : WindowMediatorSubscriberBase private bool _lightfinderIconInputInitialized = false; private int _lightfinderIconPresetIndex = -1; private bool _selectGeneralTabOnNextDraw = false; - private bool _openLightfinderSectionOnNextDraw = false; private static readonly LightlessConfig DefaultConfig = new(); + private MainSettingsTab _selectedMainTab = MainSettingsTab.General; + private TransferSettingsTab _selectedTransferTab = TransferSettingsTab.Transfers; + private ServerSettingsTab _selectedServerTab = ServerSettingsTab.CharacterManagement; + private static readonly UiSharedService.TabOption[] MainTabOptions = new[] + { + new UiSharedService.TabOption("General", MainSettingsTab.General), + new UiSharedService.TabOption("Performance", MainSettingsTab.Performance), + new UiSharedService.TabOption("Storage", MainSettingsTab.Storage), + new UiSharedService.TabOption("Transfers", MainSettingsTab.Transfers), + new UiSharedService.TabOption("Service Settings", MainSettingsTab.ServiceSettings), + new UiSharedService.TabOption("Notifications", MainSettingsTab.Notifications), + new UiSharedService.TabOption("Debug", MainSettingsTab.Debug), + }; + private readonly UiSharedService.TabOption[] _transferTabOptions = new UiSharedService.TabOption[2]; + private readonly List> _serverTabOptions = new(4); + private readonly string[] _generalTreeNavOrder = new[] + { + "Import & Export", + "Popup & Auto Fill", + "Behavior", + "Lightfinder", + "Pair List", + "Profiles", + "Colors", + "Server Info Bar", + "Nameplate", + }; + private static readonly HashSet _generalNavSeparatorAfter = new(StringComparer.Ordinal) + { + "Popup & Auto Fill", + "Profiles", + }; + private string? _generalScrollTarget = null; + private string? _generalOpenTreeTarget = null; + private readonly Dictionary _generalTreeHighlights = new(StringComparer.Ordinal); + private const float GeneralTreeHighlightDuration = 1.5f; + private readonly SeluneBrush _generalSeluneBrush = new(); + + private enum MainSettingsTab + { + General, + Performance, + Storage, + Transfers, + ServiceSettings, + Notifications, + Debug, + } + + private enum TransferSettingsTab + { + Transfers, + BlockedTransfers, + } + + private enum ServerSettingsTab + { + CharacterManagement, + SecretKeyManagement, + ServiceConfiguration, + PermissionSettings, + } private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[] { @@ -139,8 +200,8 @@ public class SettingsUi : WindowMediatorSubscriberBase SizeConstraints = new WindowSizeConstraints() { - MinimumSize = new Vector2(850f, 400f), - MaximumSize = new Vector2(850f, 2000f), + MinimumSize = new Vector2(900f, 400f), + MaximumSize = new Vector2(900f, 2000f), }; TitleBarButtons = new() @@ -167,7 +228,7 @@ public class SettingsUi : WindowMediatorSubscriberBase { IsOpen = true; _selectGeneralTabOnNextDraw = true; - _openLightfinderSectionOnNextDraw = true; + FocusGeneralTree("Lightfinder"); }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); @@ -965,89 +1026,96 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared.UnderlinedBigText("Current Transfers", UIColors.Get("LightlessBlue")); ImGuiHelpers.ScaledDummy(5); - if (ImGui.BeginTabBar("TransfersTabBar")) + _transferTabOptions[0] = new UiSharedService.TabOption( + "Transfers", + TransferSettingsTab.Transfers, + _apiController.ServerState is ServerState.Connected); + _transferTabOptions[1] = new UiSharedService.TabOption( + "Blocked Transfers", + TransferSettingsTab.BlockedTransfers); + + UiSharedService.Tab("TransferSettingsTabs", _transferTabOptions, ref _selectedTransferTab); + ImGuiHelpers.ScaledDummy(5); + + switch (_selectedTransferTab) { - if (ApiController.ServerState is ServerState.Connected && ImGui.BeginTabItem("Transfers")) - { - var uploadsSnapshot = _fileTransferManager.GetCurrentUploadsSnapshot(); - var activeUploads = uploadsSnapshot.Count(c => !c.IsTransferred); - var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); - ImGui.TextUnformatted($"Uploads (slots {activeUploads}/{uploadSlotLimit})"); - if (ImGui.BeginTable("UploadsTable", 3)) + case TransferSettingsTab.Transfers when _apiController.ServerState is ServerState.Connected: { - ImGui.TableSetupColumn("File"); - ImGui.TableSetupColumn("Uploaded"); - ImGui.TableSetupColumn("Size"); - ImGui.TableHeadersRow(); - foreach (var transfer in uploadsSnapshot) + var uploadsSnapshot = _fileTransferManager.GetCurrentUploadsSnapshot(); + var activeUploads = uploadsSnapshot.Count(c => !c.IsTransferred); + var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); + ImGui.TextUnformatted($"Uploads (slots {activeUploads}/{uploadSlotLimit})"); + if (ImGui.BeginTable("UploadsTable", 3)) { - var color = UiSharedService.UploadColor((transfer.Transferred, transfer.Total)); - using var col = ImRaii.PushColor(ImGuiCol.Text, color); - ImGui.TableNextColumn(); - if (transfer is UploadFileTransfer uploadTransfer) + ImGui.TableSetupColumn("File"); + ImGui.TableSetupColumn("Uploaded"); + ImGui.TableSetupColumn("Size"); + ImGui.TableHeadersRow(); + foreach (var transfer in uploadsSnapshot) { - ImGui.TextUnformatted(uploadTransfer.LocalFile); - } - else - { - ImGui.TextUnformatted(transfer.Hash); + var color = UiSharedService.UploadColor((transfer.Transferred, transfer.Total)); + using var col = ImRaii.PushColor(ImGuiCol.Text, color); + ImGui.TableNextColumn(); + if (transfer is UploadFileTransfer uploadTransfer) + { + ImGui.TextUnformatted(uploadTransfer.LocalFile); + } + else + { + ImGui.TextUnformatted(transfer.Hash); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Transferred)); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Total)); } - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Transferred)); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(transfer.Total)); + ImGui.EndTable(); } - ImGui.EndTable(); - } - - ImGui.Separator(); - ImGui.TextUnformatted("Downloads"); - if (ImGui.BeginTable("DownloadsTable", 4)) - { - ImGui.TableSetupColumn("User"); - ImGui.TableSetupColumn("Server"); - ImGui.TableSetupColumn("Files"); - ImGui.TableSetupColumn("Download"); - ImGui.TableHeadersRow(); - - foreach (var transfer in _currentDownloads.ToArray()) + ImGui.Separator(); + ImGui.TextUnformatted("Downloads"); + if (ImGui.BeginTable("DownloadsTable", 4)) { - var userName = transfer.Key.Name; - foreach (var entry in transfer.Value) + ImGui.TableSetupColumn("User"); + ImGui.TableSetupColumn("Server"); + ImGui.TableSetupColumn("Files"); + ImGui.TableSetupColumn("Download"); + ImGui.TableHeadersRow(); + + foreach (var transfer in _currentDownloads.ToArray()) { - var color = UiSharedService.UploadColor((entry.Value.TransferredBytes, - entry.Value.TotalBytes)); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(userName); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry.Key); - var col = ImRaii.PushColor(ImGuiCol.Text, color); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(entry.Value.TransferredFiles + "/" + entry.Value.TotalFiles); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(UiSharedService.ByteToString(entry.Value.TransferredBytes) + "/" + - UiSharedService.ByteToString(entry.Value.TotalBytes)); - ImGui.TableNextColumn(); - col.Dispose(); - ImGui.TableNextRow(); + var userName = transfer.Key.Name; + foreach (var entry in transfer.Value) + { + var color = UiSharedService.UploadColor((entry.Value.TransferredBytes, + entry.Value.TotalBytes)); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(userName); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.Key); + var col = ImRaii.PushColor(ImGuiCol.Text, color); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.Value.TransferredFiles + "/" + entry.Value.TotalFiles); + ImGui.TableNextColumn(); + ImGui.TextUnformatted( + UiSharedService.ByteToString(entry.Value.TransferredBytes) + "/" + + UiSharedService.ByteToString(entry.Value.TotalBytes)); + ImGui.TableNextColumn(); + col.Dispose(); + ImGui.TableNextRow(); + } } + + ImGui.EndTable(); } - ImGui.EndTable(); + break; } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Blocked Transfers")) - { + case TransferSettingsTab.BlockedTransfers: DrawBlockedTransfers(); - ImGui.EndTabItem(); - } - - ImGui.EndTabBar(); + break; } } @@ -1499,72 +1567,100 @@ public class SettingsUi : WindowMediatorSubscriberBase _lastTab = "General"; + using var generalSelune = Selune.Begin(_generalSeluneBrush, ImGui.GetWindowDrawList(), ImGui.GetWindowPos(), ImGui.GetWindowSize()); + + var navAvailableWidth = ImGui.GetContentRegionAvail().X; + var minNavWidth = 80f * ImGuiHelpers.GlobalScale; + var maxNavWidth = 150f * ImGuiHelpers.GlobalScale; + var navWidth = Math.Max(minNavWidth, Math.Min(maxNavWidth, navAvailableWidth * 0.24f)); + var navHeight = MathF.Max(ImGui.GetContentRegionAvail().Y, 400f * ImGuiHelpers.GlobalScale); + var style = ImGui.GetStyle(); + + ImGui.BeginGroup(); + ImGui.BeginChild("GeneralNavigation", new Vector2(navWidth, navHeight), true); + DrawGeneralNavigation(); + ImGui.EndChild(); + ImGui.EndGroup(); + + ImGui.SameLine(0, style.ItemSpacing.X * 1.75f); + + ImGui.BeginGroup(); + ImGui.BeginChild("GeneralSettingsContent", new Vector2(0, navHeight), false); + _uiShared.UnderlinedBigText("General Settings", UIColors.Get("LightlessBlue")); ImGui.Dummy(new Vector2(10)); _uiShared.BigText("Notes"); - if (_uiShared.MediumTreeNode("Import & Export", UIColors.Get("LightlessPurple"))) + using (var importExportTree = BeginGeneralTree("Import & Export", UIColors.Get("LightlessPurple"))) { - if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard")) + if (importExportTree.Visible) { - var snapshot = _pairUiService.GetSnapshot(); - ImGui.SetClipboardText(UiSharedService.GetNotes(snapshot.DirectPairs - .UnionBy(snapshot.GroupPairs.SelectMany(p => p.Value), p => p.UserData, - UserDataComparer.Instance).ToList())); - } + if (_uiShared.IconTextButton(FontAwesomeIcon.StickyNote, "Export all your user notes to clipboard")) + { + var snapshot = _pairUiService.GetSnapshot(); + ImGui.SetClipboardText(UiSharedService.GetNotes(snapshot.DirectPairs + .UnionBy(snapshot.GroupPairs.SelectMany(p => p.Value), p => p.UserData, + UserDataComparer.Instance).ToList())); + } - if (_uiShared.IconTextButton(FontAwesomeIcon.FileImport, "Import notes from clipboard")) - { - _notesSuccessfullyApplied = null; - var notes = ImGui.GetClipboardText(); - _notesSuccessfullyApplied = _uiShared.ApplyNotesFromClipboard(notes, _overwriteExistingLabels); - } + if (_uiShared.IconTextButton(FontAwesomeIcon.FileImport, "Import notes from clipboard")) + { + _notesSuccessfullyApplied = null; + var notes = ImGui.GetClipboardText(); + _notesSuccessfullyApplied = _uiShared.ApplyNotesFromClipboard(notes, _overwriteExistingLabels); + } - ImGui.SameLine(); - ImGui.Checkbox("Overwrite existing notes", ref _overwriteExistingLabels); - _uiShared.DrawHelpText( - "If this option is selected all already existing notes for UIDs will be overwritten by the imported notes."); - if (_notesSuccessfullyApplied.HasValue && _notesSuccessfullyApplied.Value) - { - UiSharedService.ColorTextWrapped("User Notes successfully imported", UIColors.Get("LightlessBlue")); - } - else if (_notesSuccessfullyApplied.HasValue && !_notesSuccessfullyApplied.Value) - { - UiSharedService.ColorTextWrapped( - "Attempt to import notes from clipboard failed. Check formatting and try again", - ImGuiColors.DalamudRed); - } + ImGui.SameLine(); + ImGui.Checkbox("Overwrite existing notes", ref _overwriteExistingLabels); + _uiShared.DrawHelpText( + "If this option is selected all already existing notes for UIDs will be overwritten by the imported notes."); + if (_notesSuccessfullyApplied.HasValue && _notesSuccessfullyApplied.Value) + { + UiSharedService.ColorTextWrapped("User Notes successfully imported", UIColors.Get("LightlessBlue")); + } + else if (_notesSuccessfullyApplied.HasValue && !_notesSuccessfullyApplied.Value) + { + UiSharedService.ColorTextWrapped( + "Attempt to import notes from clipboard failed. Check formatting and try again", + ImGuiColors.DalamudRed); + } - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + importExportTree.MarkContentEnd(); + } } ImGui.Separator(); var openPopupOnAddition = _configService.Current.OpenPopupOnAdd; - if (_uiShared.MediumTreeNode("Popup & Auto Fill", UIColors.Get("LightlessPurple"))) + using (var popupTree = BeginGeneralTree("Popup & Auto Fill", UIColors.Get("LightlessPurple"))) { - if (ImGui.Checkbox("Open Notes Popup on user addition", ref openPopupOnAddition)) + if (popupTree.Visible) { - _configService.Current.OpenPopupOnAdd = openPopupOnAddition; - _configService.Save(); + if (ImGui.Checkbox("Open Notes Popup on user addition", ref openPopupOnAddition)) + { + _configService.Current.OpenPopupOnAdd = openPopupOnAddition; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); + + var autoPopulateNotes = _configService.Current.AutoPopulateEmptyNotesFromCharaName; + if (ImGui.Checkbox("Automatically populate notes using player names", ref autoPopulateNotes)) + { + _configService.Current.AutoPopulateEmptyNotesFromCharaName = autoPopulateNotes; + _configService.Save(); + } + + _uiShared.DrawHelpText( + "This will automatically populate user notes using the first encountered player name if the note was not set prior"); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + popupTree.MarkContentEnd(); } - - _uiShared.DrawHelpText( - "This will open a popup that allows you to set the notes for a user after successfully adding them to your individual pairs."); - - var autoPopulateNotes = _configService.Current.AutoPopulateEmptyNotesFromCharaName; - if (ImGui.Checkbox("Automatically populate notes using player names", ref autoPopulateNotes)) - { - _configService.Current.AutoPopulateEmptyNotesFromCharaName = autoPopulateNotes; - _configService.Save(); - } - - _uiShared.DrawHelpText( - "This will automatically populate user notes using the first encountered player name if the note was not set prior"); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); } ImGui.Separator(); @@ -1595,66 +1691,59 @@ public class SettingsUi : WindowMediatorSubscriberBase var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; - if (_uiShared.MediumTreeNode("Behavior", UIColors.Get("LightlessPurple"))) + using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { - if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) + if (behaviorTree.Visible) { - _configService.Current.EnableRightClickMenus = enableRightClickMenu; - _configService.Save(); - } - - _uiShared.DrawHelpText("This will add all Lightless related right click menu entries in the game UI."); - - if (ImGui.Checkbox("Display status and visible pair count in Server Info Bar", ref enableDtrEntry)) - { - _configService.Current.EnableDtrEntry = enableDtrEntry; - _configService.Save(); - } - - _uiShared.DrawHelpText( - "This will add Lightless connection status and visible pair count in the Server Info Bar.\nYou can further configure this through your Dalamud Settings."); - - using (ImRaii.Disabled(!enableDtrEntry)) - { - using var indent = ImRaii.PushIndent(); - if (ImGui.Checkbox("Show visible character's UID in tooltip", ref showUidInDtrTooltip)) + if (ImGui.Checkbox("Enable Game Right Click Menu Entries", ref enableRightClickMenu)) { - _configService.Current.ShowUidInDtrTooltip = showUidInDtrTooltip; + _configService.Current.EnableRightClickMenus = enableRightClickMenu; _configService.Save(); } - if (ImGui.Checkbox("Prefer notes over player names in tooltip", ref preferNoteInDtrTooltip)) + _uiShared.DrawHelpText("This will add all Lightless related right click menu entries in the game UI."); + + if (ImGui.Checkbox("Display status and visible pair count in Server Info Bar", ref enableDtrEntry)) { - _configService.Current.PreferNoteInDtrTooltip = preferNoteInDtrTooltip; + _configService.Current.EnableDtrEntry = enableDtrEntry; _configService.Save(); } - } + _uiShared.DrawHelpText( + "This will add Lightless connection status and visible pair count in the Server Info Bar.\nYou can further configure this through your Dalamud Settings."); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); + using (ImRaii.Disabled(!enableDtrEntry)) + { + using var indent = ImRaii.PushIndent(); + if (ImGui.Checkbox("Show visible character's UID in tooltip", ref showUidInDtrTooltip)) + { + _configService.Current.ShowUidInDtrTooltip = showUidInDtrTooltip; + _configService.Save(); + } + + if (ImGui.Checkbox("Prefer notes over player names in tooltip", ref preferNoteInDtrTooltip)) + { + _configService.Current.PreferNoteInDtrTooltip = preferNoteInDtrTooltip; + _configService.Save(); + } + + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + behaviorTree.MarkContentEnd(); + } } ImGui.Separator(); - var forceOpenLightfinder = _openLightfinderSectionOnNextDraw; - if (_openLightfinderSectionOnNextDraw) + using (var lightfinderTree = BeginGeneralTree("Lightfinder", UIColors.Get("LightlessPurple"))) { - ImGui.SetNextItemOpen(true, ImGuiCond.Always); - } - - if (_uiShared.MediumTreeNode("Lightfinder", UIColors.Get("LightlessPurple"))) - { - if (forceOpenLightfinder) + if (lightfinderTree.Visible) { - ImGui.SetScrollHereY(); - } - - _openLightfinderSectionOnNextDraw = false; - - bool autoEnable = _configService.Current.LightfinderAutoEnableOnConnect; - var autoAlign = _configService.Current.LightfinderAutoAlign; - var offsetX = (int)_configService.Current.LightfinderLabelOffsetX; + 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; @@ -2094,356 +2183,542 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); + lightfinderTree.MarkContentEnd(); + } } ImGui.Separator(); - if (_uiShared.MediumTreeNode("Colors", UIColors.Get("LightlessPurple"))) + using (var pairListTree = BeginGeneralTree("Pair List", UIColors.Get("LightlessPurple"))) { - ImGui.TextUnformatted("UI Theme Colors"); - - var colorNames = new[] + if (pairListTree.Visible) { - ("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"), - ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), - ("LightlessGreen", "Success Green", "Join buttons and success messages"), - ("LightlessYellow", "Warning Yellow", "Warning colors"), - ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), - ("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.Checkbox("Show separate Visible group", ref showVisibleSeparate)) { - ImGui.TableNextRow(); + _configService.Current.ShowVisibleUsersSeparately = showVisibleSeparate; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } - // color column - ImGui.TableSetColumnIndex(0); - var currentColor = UIColors.Get(colorKey); - var colorToEdit = currentColor; - if (ImGui.ColorEdit4($"##color_{colorKey}", ref colorToEdit, - ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf)) + _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)) { - UIColors.Set(colorKey, colorToEdit); + _configService.Current.ShowSyncshellUsersInVisible = groupInVisible; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); } + } - ImGui.SameLine(); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(displayName); + if (ImGui.Checkbox("Show separate Offline group", ref showOfflineSeparate)) + { + _configService.Current.ShowOfflineUsersSeparately = showOfflineSeparate; + _configService.Save(); + Mediator.Publish(new RefreshUiMessage()); + } - // description column - ImGui.TableSetColumnIndex(1); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(description); + _uiShared.DrawHelpText( + "This will show all currently offline users in a special 'Offline' group in the main UI."); - // actions column - 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.Disabled(!showOfflineSeparate)) + { + using var indent = ImRaii.PushIndent(); + if (ImGui.Checkbox("Show separate Offline group for Syncshell users", ref syncshellOfflineSeparate)) { - using (ImRaii.PushFont(UiBuilder.IconFont)) + _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(); + } + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGui.TreePop(); + pairListTree.MarkContentEnd(); + } + } + + ImGui.Separator(); + + using (var profilesTree = BeginGeneralTree("Profiles", UIColors.Get("LightlessPurple"))) + { + 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"), + ("LightlessBlue", "Secondary Blue", "Secondary title colors, visable pairs"), + ("LightlessGreen", "Success Green", "Join buttons and success messages"), + ("LightlessYellow", "Warning Yellow", "Warning colors"), + ("LightlessOrange", "Performance Orange", "Performance notifications and warnings"), + ("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) + { + ImGui.TableNextRow(); + + 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("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(); - - ImGui.Spacing(); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault"), 1.5f); - - 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)) + ImGui.Spacing(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Undo, "Reset All Theme Colors")) { - _configService.Current.NameplateColors = nameColors; - _configService.Save(); - _nameplateService.RequestRedraw(); + UIColors.ResetAll(); } - if (ImGui.Checkbox("Override friend color", ref isFriendOverride)) + _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.overrideFriendColor = isFriendOverride; + _configService.Current.UseLightlessRedesign = useLightlessRedesign; _configService.Save(); - _nameplateService.RequestRedraw(); } - if (ImGui.Checkbox("Override party color", ref isPartyOverride)) + var usePairColoredUIDs = _configService.Current.useColoredUIDs; + + if (ImGui.Checkbox("Toggle the colored UID's in pair list", ref usePairColoredUIDs)) { - _configService.Current.overridePartyColor = isPartyOverride; + _configService.Current.useColoredUIDs = usePairColoredUIDs; _configService.Save(); - _nameplateService.RequestRedraw(); } + + _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(); - - 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(); } ImGui.Separator(); - if (_uiShared.MediumTreeNode("Pair List", UIColors.Get("LightlessPurple"))) + using (var serverInfoTree = BeginGeneralTree("Server Info Bar", UIColors.Get("LightlessPurple"))) { - if (ImGui.Checkbox("Show separate Visible group", ref showVisibleSeparate)) + if (serverInfoTree.Visible) { - _configService.Current.ShowVisibleUsersSeparately = showVisibleSeparate; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); - } + ImGui.TextUnformatted("Server Info Bar Colors"); - _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 (ImGui.Checkbox("Color-code the Server Info Bar entry according to status", ref useColorsInDtr)) { - _configService.Current.ShowSyncshellUsersInVisible = groupInVisible; + _configService.Current.UseColorsInDtr = useColorsInDtr; _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 color the Server Info Bar entry based on connection status and visible pairs."); - _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)) + ImGui.BeginDisabled(!useColorsInDtr); + const ImGuiTableFlags serverInfoTableFlags = ImGuiTableFlags.Borders | ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingFixedFit; + if (ImGui.BeginTable("##ServerInfoBarColorTable", 3, serverInfoTableFlags)) { - _configService.Current.ShowSyncshellOfflineUsersSeparately = syncshellOfflineSeparate; - _configService.Save(); - Mediator.Publish(new RefreshUiMessage()); + 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(); } - - 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(); - } - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); - ImGui.TreePop(); } ImGui.Separator(); - if (_uiShared.MediumTreeNode("Profiles", UIColors.Get("LightlessPurple"))) + + using (var nameplateTree = BeginGeneralTree("Nameplate", UIColors.Get("LightlessPurple"))) { - if (ImGui.Checkbox("Show Lightless Profiles on Hover", ref showProfiles)) + if (nameplateTree.Visible) { - Mediator.Publish(new ClearProfileUserDataMessage()); - _configService.Current.ProfilesShow = showProfiles; - _configService.Save(); + 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 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(); } + ImGui.Separator(); + + ImGui.EndChild(); + ImGui.EndGroup(); + + generalSelune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + } + + private void DrawGeneralNavigation() + { + var buttonWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X); + var buttonHeight = Math.Max(ImGui.GetFrameHeight(), 36f * ImGuiHelpers.GlobalScale); + for (var i = 0; i < _generalTreeNavOrder.Length; i++) + { + var label = _generalTreeNavOrder[i]; + using var id = ImRaii.PushId(label); + var isTarget = string.Equals(_generalOpenTreeTarget, label, StringComparison.Ordinal) || + string.Equals(_generalScrollTarget, label, StringComparison.Ordinal); + using var activeColor = isTarget + ? ImRaii.PushColor(ImGuiCol.Button, ImGui.GetStyle().Colors[(int)ImGuiCol.TabActive]) + : null; + using var activeHover = isTarget + ? ImRaii.PushColor(ImGuiCol.ButtonHovered, ImGui.GetStyle().Colors[(int)ImGuiCol.TabHovered]) + : null; + using var activeActive = isTarget + ? ImRaii.PushColor(ImGuiCol.ButtonActive, ImGui.GetStyle().Colors[(int)ImGuiCol.TabActive]) + : null; + + if (ImGui.Button(label, new Vector2(buttonWidth, buttonHeight))) + { + FocusGeneralTree(label); + } + + if (_generalNavSeparatorAfter.Contains(label) && i < _generalTreeNavOrder.Length - 1) + { + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + } + } + } + + private GeneralTreeScope BeginGeneralTree(string label, Vector4 color) + { + var shouldForceOpen = string.Equals(_generalOpenTreeTarget, label, StringComparison.Ordinal); + if (shouldForceOpen) + { + ImGui.SetNextItemOpen(true, ImGuiCond.Always); + } + + var open = _uiShared.MediumTreeNode(label, color); + if (shouldForceOpen) + { + _generalOpenTreeTarget = null; + } + + var headerMin = ImGui.GetItemRectMin(); + var headerMax = ImGui.GetItemRectMax(); + var windowPos = ImGui.GetWindowPos(); + var contentRegionMin = windowPos + ImGui.GetWindowContentRegionMin(); + var contentRegionMax = windowPos + ImGui.GetWindowContentRegionMax(); + + if (open && string.Equals(_generalScrollTarget, label, StringComparison.Ordinal)) + { + ImGui.SetScrollHereY(0f); + _generalScrollTarget = null; + } + + return new GeneralTreeScope(open, color, GetGeneralTreeHighlightAlpha(label), headerMin, headerMax, contentRegionMin, contentRegionMax); + } + + private void FocusGeneralTree(string label) + { + _generalOpenTreeTarget = label; + _generalScrollTarget = label; + _generalTreeHighlights[label] = ImGui.GetTime(); + } + + private float GetGeneralTreeHighlightAlpha(string label) + { + if (!_generalTreeHighlights.TryGetValue(label, out var startTime)) + return 0f; + + var elapsed = (float)(ImGui.GetTime() - startTime); + if (elapsed >= GeneralTreeHighlightDuration) + { + _generalTreeHighlights.Remove(label); + return 0f; + } + + return 1f - (elapsed / GeneralTreeHighlightDuration); + } + + private struct GeneralTreeScope : IDisposable + { + private readonly bool _visible; + private readonly Vector4 _color; + private readonly float _highlightAlpha; + private readonly Vector2 _headerMin; + private readonly Vector2 _headerMax; + private readonly Vector2 _contentRegionMin; + private readonly Vector2 _contentRegionMax; + private Vector2 _contentEnd; + private bool _hasContentEnd; + + public bool Visible => _visible; + + public GeneralTreeScope(bool visible, Vector4 color, float highlightAlpha, Vector2 headerMin, Vector2 headerMax, Vector2 contentRegionMin, Vector2 contentRegionMax) + { + _visible = visible; + _color = color; + _highlightAlpha = highlightAlpha; + _headerMin = headerMin; + _headerMax = headerMax; + _contentRegionMin = contentRegionMin; + _contentRegionMax = contentRegionMax; + _contentEnd = Vector2.Zero; + _hasContentEnd = false; + } + + public void MarkContentEnd() + { + if (!_visible) + return; + + _contentEnd = ImGui.GetCursorScreenPos(); + _hasContentEnd = true; + } + + public void Dispose() + { + if (_highlightAlpha <= 0f) + return; + + var style = ImGui.GetStyle(); + var rectMin = new Vector2(_contentRegionMin.X, _headerMin.Y) - new Vector2(0f, 2f); + var rectMax = new Vector2(_contentRegionMax.X, _headerMax.Y) + new Vector2(0f, 2f); + + if (_visible) + { + var contentEnd = _hasContentEnd ? _contentEnd : ImGui.GetCursorScreenPos(); + rectMax.Y = Math.Max(rectMax.Y, contentEnd.Y + style.ItemSpacing.Y + 2f); + } + + Selune.RegisterHighlight( + rectMin, + rectMax, + SeluneHighlightMode.Both, + borderOnly: false, + exactSize: true, + clipToElement: true, + clipPadding: new Vector2(0f, 4f), + highlightColorOverride: new Vector4(_color.X, _color.Y, _color.Z, 0.4f), + highlightAlphaOverride: _highlightAlpha); + } } private void DrawPerformance() @@ -2938,530 +3213,555 @@ public class SettingsUi : WindowMediatorSubscriberBase bool useOauth = selectedServer.UseOAuth2; - if (ImGui.BeginTabBar("serverTabBar")) + _serverTabOptions.Clear(); + _serverTabOptions.Add(new UiSharedService.TabOption( + "Character Management", + ServerSettingsTab.CharacterManagement)); + + if (!useOauth) { - if (ImGui.BeginTabItem("Character Management")) - { - if (selectedServer.SecretKeys.Any() || useOauth) - { - UiSharedService.ColorTextWrapped( - "Characters listed here will automatically connect to the selected Lightless service with the settings as provided below." + - " Make sure to enter the character names correctly or use the 'Add current character' button at the bottom.", - UIColors.Get("LightlessYellow")); - int i = 0; - _uiShared.DrawUpdateOAuthUIDsButton(selectedServer); + _serverTabOptions.Add(new UiSharedService.TabOption( + "Secret Key Management", + ServerSettingsTab.SecretKeyManagement)); + } - if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken)) - { - bool hasSetSecretKeysButNoUid = - selectedServer.Authentications.Exists(u => - u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)); - if (hasSetSecretKeysButNoUid) - { - ImGui.Dummy(new(5f, 5f)); - UiSharedService.TextWrapped( - "Some entries have been detected that have previously been assigned secret keys but not UIDs. " + - "Press this button below to attempt to convert those entries."); - using (ImRaii.Disabled(_secretKeysConversionTask != null && - !_secretKeysConversionTask.IsCompleted)) - { - if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowsLeftRight, - "Try to Convert Secret Keys to UIDs")) - { - _secretKeysConversionTask = - ConvertSecretKeysToUIDs(selectedServer, _secretKeysConversionCts.Token); - } - } + _serverTabOptions.Add(new UiSharedService.TabOption( + "Service Configuration", + ServerSettingsTab.ServiceConfiguration)); + _serverTabOptions.Add(new UiSharedService.TabOption( + "Permission Settings", + ServerSettingsTab.PermissionSettings)); - if (_secretKeysConversionTask != null && !_secretKeysConversionTask.IsCompleted) - { - UiSharedService.ColorTextWrapped("Converting Secret Keys to UIDs", - UIColors.Get("LightlessYellow")); - } + UiSharedService.Tab("ServerSettingsTabs", _serverTabOptions, ref _selectedServerTab); + ImGuiHelpers.ScaledDummy(5); - if (_secretKeysConversionTask != null && _secretKeysConversionTask.IsCompletedSuccessfully) - { - Vector4? textColor = null; - if (_secretKeysConversionTask.Result.PartialSuccess) - { - textColor = UIColors.Get("LightlessYellow"); - } - - if (!_secretKeysConversionTask.Result.Success) - { - textColor = ImGuiColors.DalamudRed; - } - - string text = $"Conversion has completed: {_secretKeysConversionTask.Result.Result}"; - if (textColor == null) - { - UiSharedService.TextWrapped(text); - } - else - { - UiSharedService.ColorTextWrapped(text, textColor!.Value); - } - - if (!_secretKeysConversionTask.Result.Success || - _secretKeysConversionTask.Result.PartialSuccess) - { - UiSharedService.TextWrapped( - "In case of conversion failures, please set the UIDs for the failed conversions manually."); - } - } - } - } - - ImGui.Separator(); - string youName = _dalamudUtilService.GetPlayerName(); - uint youWorld = _dalamudUtilService.GetHomeWorldId(); - ulong youCid = _dalamudUtilService.GetCID(); - if (!selectedServer.Authentications.Exists(a => - string.Equals(a.CharacterName, youName, StringComparison.Ordinal) && a.WorldId == youWorld)) - { - _uiShared.BigText("Your Character is not Configured", ImGuiColors.DalamudRed); - UiSharedService.ColorTextWrapped( - "You have currently no character configured that corresponds to your current name and world.", - ImGuiColors.DalamudRed); - var authWithCid = selectedServer.Authentications.Find(f => f.LastSeenCID == youCid); - if (authWithCid != null) - { - ImGuiHelpers.ScaledDummy(5); - UiSharedService.ColorText( - "A potential rename/world change from this character was detected:", - UIColors.Get("LightlessYellow")); - using (ImRaii.PushIndent(10f)) - UiSharedService.ColorText( - "Entry: " + authWithCid.CharacterName + " - " + - _dalamudUtilService.WorldData.Value[(ushort)authWithCid.WorldId], - UIColors.Get("LightlessBlue")); - UiSharedService.ColorText( - "Press the button below to adjust that entry to your current character:", - UIColors.Get("LightlessYellow")); - using (ImRaii.PushIndent(10f)) - UiSharedService.ColorText( - "Current: " + youName + " - " + - _dalamudUtilService.WorldData.Value[(ushort)youWorld], - UIColors.Get("LightlessBlue")); - ImGuiHelpers.ScaledDummy(5); - if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowRight, - "Update Entry to Current Character")) - { - authWithCid.CharacterName = youName; - authWithCid.WorldId = youWorld; - _serverConfigurationManager.Save(); - } - } - - ImGuiHelpers.ScaledDummy(5); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(5); - } - - foreach (var item in selectedServer.Authentications.ToList()) - { - using var charaId = ImRaii.PushId("selectedChara" + i); - - var worldIdx = (ushort)item.WorldId; - var data = _uiShared.WorldData.OrderBy(u => u.Value, StringComparer.Ordinal) - .ToDictionary(k => k.Key, k => k.Value); - if (!data.TryGetValue(worldIdx, out string? worldPreview)) - { - worldPreview = data.First().Value; - } - - Dictionary keys = []; - - if (!useOauth) - { - var secretKeyIdx = item.SecretKeyIdx; - keys = selectedServer.SecretKeys; - if (!keys.TryGetValue(secretKeyIdx, out var secretKey)) - { - secretKey = new(); - } - } - - bool thisIsYou = false; - if (string.Equals(youName, item.CharacterName, StringComparison.OrdinalIgnoreCase) - && youWorld == worldIdx) - { - thisIsYou = true; - } - - bool misManaged = false; - if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken) && - string.IsNullOrEmpty(item.UID)) - { - misManaged = true; - } - - if (!selectedServer.UseOAuth2 && item.SecretKeyIdx == -1) - { - misManaged = true; - } - - Vector4 color = UIColors.Get("LightlessBlue"); - string text = thisIsYou ? "Your Current Character" : string.Empty; - if (misManaged) - { - text += " [MISMANAGED (" + (selectedServer.UseOAuth2 ? "No UID Set" : "No Secret Key Set") + - ")]"; - color = ImGuiColors.DalamudRed; - } - - if (selectedServer.Authentications.Where(e => e != item).Any(e => - string.Equals(e.CharacterName, item.CharacterName, StringComparison.Ordinal) - && e.WorldId == item.WorldId)) - { - text += " [DUPLICATE]"; - color = ImGuiColors.DalamudRed; - } - - if (!string.IsNullOrEmpty(text)) - { - text = text.Trim(); - _uiShared.BigText(text, color); - } - - var charaName = item.CharacterName; - if (ImGui.InputText("Character Name", ref charaName, 64)) - { - item.CharacterName = charaName; - _serverConfigurationManager.Save(); - } - - _uiShared.DrawCombo("World##" + item.CharacterName + i, data, (w) => w.Value, - (w) => - { - if (item.WorldId != w.Key) - { - item.WorldId = w.Key; - _serverConfigurationManager.Save(); - } - }, - EqualityComparer>.Default.Equals( - data.FirstOrDefault(f => f.Key == worldIdx), default) - ? data.First() - : data.First(f => f.Key == worldIdx)); - - if (!useOauth) - { - _uiShared.DrawCombo("Secret Key###" + item.CharacterName + i, keys, - (w) => w.Value.FriendlyName, - (w) => - { - if (w.Key != item.SecretKeyIdx) - { - item.SecretKeyIdx = w.Key; - _serverConfigurationManager.Save(); - } - }, - EqualityComparer>.Default.Equals( - keys.FirstOrDefault(f => f.Key == item.SecretKeyIdx), default) - ? keys.First() - : keys.First(f => f.Key == item.SecretKeyIdx)); - } - else - { - _uiShared.DrawUIDComboForAuthentication(i, item, selectedServer.ServerUri, _logger); - } - - bool isAutoLogin = item.AutoLogin; - if (ImGui.Checkbox("Automatically login to Lightless", ref isAutoLogin)) - { - item.AutoLogin = isAutoLogin; - _serverConfigurationManager.Save(); - } - - _uiShared.DrawHelpText( - "When enabled and logging into this character in XIV, Lightless will automatically connect to the current service."); - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Character") && - UiSharedService.CtrlPressed()) - _serverConfigurationManager.RemoveCharacterFromServer(idx, item); - UiSharedService.AttachToolTip("Hold CTRL to delete this entry."); - - i++; - if (item != selectedServer.Authentications.ToList()[^1]) - { - ImGuiHelpers.ScaledDummy(5); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(5); - } - } - - if (selectedServer.Authentications.Any()) - ImGui.Separator(); - - if (!selectedServer.Authentications.Exists(c => - string.Equals(c.CharacterName, youName, StringComparison.Ordinal) - && c.WorldId == youWorld)) - { - if (_uiShared.IconTextButton(FontAwesomeIcon.User, "Add current character")) - { - _serverConfigurationManager.AddCurrentCharacterToServer(idx); - } - - ImGui.SameLine(); - } - - if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add new character")) - { - _serverConfigurationManager.AddEmptyCharacterToServer(idx); - } - } - else - { - UiSharedService.ColorTextWrapped("You need to add a Secret Key first before adding Characters.", - UIColors.Get("LightlessYellow")); - } - - ImGui.EndTabItem(); - } - - if (!useOauth && ImGui.BeginTabItem("Secret Key Management")) - { - foreach (var item in selectedServer.SecretKeys.ToList()) - { - using var id = ImRaii.PushId("key" + item.Key); - var friendlyName = item.Value.FriendlyName; - if (ImGui.InputText("Secret Key Display Name", ref friendlyName, 255)) - { - item.Value.FriendlyName = friendlyName; - _serverConfigurationManager.Save(); - } - - var key = item.Value.Key; - if (ImGui.InputText("Secret Key", ref key, 64)) - { - item.Value.Key = key; - _serverConfigurationManager.Save(); - } - - if (!selectedServer.Authentications.Exists(p => p.SecretKeyIdx == item.Key)) - { - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Secret Key") && - UiSharedService.CtrlPressed()) - { - selectedServer.SecretKeys.Remove(item.Key); - _serverConfigurationManager.Save(); - } - - UiSharedService.AttachToolTip("Hold CTRL to delete this secret key entry"); - } - else - { - UiSharedService.ColorTextWrapped("This key is in use and cannot be deleted", - UIColors.Get("LightlessYellow")); - } - - if (item.Key != selectedServer.SecretKeys.Keys.LastOrDefault()) - ImGui.Separator(); - } - - ImGui.Separator(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add new Secret Key")) - { - selectedServer.SecretKeys.Add( - selectedServer.SecretKeys.Any() ? selectedServer.SecretKeys.Max(p => p.Key) + 1 : 0, - new SecretKey() { FriendlyName = "New Secret Key", }); - _serverConfigurationManager.Save(); - } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Service Configuration")) - { - var serverName = selectedServer.ServerName; - var serverUri = selectedServer.ServerUri; - var isMain = string.Equals(serverName, ApiController.MainServer, StringComparison.OrdinalIgnoreCase); - var flags = isMain ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; - - if (ImGui.InputText("Service URI", ref serverUri, 255, flags)) - { - selectedServer.ServerUri = serverUri; - } - - if (isMain) - { - _uiShared.DrawHelpText("You cannot edit the URI of the main service."); - } - - if (ImGui.InputText("Service Name", ref serverName, 255, flags)) - { - selectedServer.ServerName = serverName; - _serverConfigurationManager.Save(); - } - - if (isMain) - { - _uiShared.DrawHelpText("You cannot edit the name of the main service."); - } - - ImGui.SetNextItemWidth(200); - var serverTransport = _serverConfigurationManager.GetTransport(); - _uiShared.DrawCombo("Server Transport Type", - Enum.GetValues().Where(t => t != HttpTransportType.None), - (v) => v.ToString(), - onSelected: (t) => _serverConfigurationManager.SetTransportType(t), - serverTransport); - _uiShared.DrawHelpText( - "You normally do not need to change this, if you don't know what this is or what it's for, keep it to WebSockets." + - Environment.NewLine - + "If you run into connection issues with e.g. VPNs, try ServerSentEvents first before trying out LongPolling." + - UiSharedService.TooltipSeparator - + "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling"); - - ImGuiHelpers.ScaledDummy(5); - - if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth)) - { - selectedServer.UseOAuth2 = useOauth; - _serverConfigurationManager.Save(); - } - - _uiShared.DrawHelpText( - "Use Discord OAuth2 Authentication to identify with this server instead of secret keys"); - if (useOauth) - { - _uiShared.DrawOAuth(selectedServer); - if (string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer))) - { - ImGuiHelpers.ScaledDummy(10f); - UiSharedService.ColorTextWrapped( - "You have enabled OAuth2 but it is not linked. Press the buttons Check, then Authenticate to link properly.", - ImGuiColors.DalamudRed); - } - - if (!string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer)) - && selectedServer.Authentications.TrueForAll(u => string.IsNullOrEmpty(u.UID))) - { - ImGuiHelpers.ScaledDummy(10f); - UiSharedService.ColorTextWrapped( - "You have enabled OAuth2 but no characters configured. Set the correct UIDs for your characters in \"Character Management\".", - ImGuiColors.DalamudRed); - } - } - - if (!isMain && selectedServer != _serverConfigurationManager.CurrentServer) - { - ImGui.Separator(); - if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Service") && - UiSharedService.CtrlPressed()) - { - _serverConfigurationManager.DeleteServer(selectedServer); - } - - _uiShared.DrawHelpText("Hold CTRL to delete this service"); - } - - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Permission Settings")) - { - _uiShared.BigText("Default Permission Settings"); - if (selectedServer == _serverConfigurationManager.CurrentServer && _apiController.IsConnected) - { - UiSharedService.TextWrapped( - "Note: The default permissions settings here are not applied retroactively to existing pairs or joined Syncshells."); - UiSharedService.TextWrapped( - "Note: The default permissions settings here are sent and stored on the connected service."); - ImGuiHelpers.ScaledDummy(5f); - var perms = _apiController.DefaultPermissions!; - bool individualIsSticky = perms.IndividualIsSticky; - bool disableIndividualSounds = perms.DisableIndividualSounds; - bool disableIndividualAnimations = perms.DisableIndividualAnimations; - bool disableIndividualVFX = perms.DisableIndividualVFX; - if (ImGui.Checkbox("Individually set permissions become preferred permissions", - ref individualIsSticky)) - { - perms.IndividualIsSticky = individualIsSticky; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText( - "The preferred attribute means that the permissions to that user will never change through any of your permission changes to Syncshells " + - "(i.e. if you have paused one specific user in a Syncshell and they become preferred permissions, then pause and unpause the same Syncshell, the user will remain paused - " + - "if a user does not have preferred permissions, it will follow the permissions of the Syncshell and be unpaused)." + - Environment.NewLine + Environment.NewLine + - "This setting means:" + Environment.NewLine + - " - All new individual pairs get their permissions defaulted to preferred permissions." + - Environment.NewLine + - " - All individually set permissions for any pair will also automatically become preferred permissions. This includes pairs in Syncshells." + - Environment.NewLine + Environment.NewLine + - "It is possible to remove or set the preferred permission state for any pair at any time." + - Environment.NewLine + Environment.NewLine + - "If unsure, leave this setting off."); - ImGuiHelpers.ScaledDummy(3f); - - if (ImGui.Checkbox("Disable individual pair sounds", ref disableIndividualSounds)) - { - perms.DisableIndividualSounds = disableIndividualSounds; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText("This setting will disable sound sync for all new individual pairs."); - if (ImGui.Checkbox("Disable individual pair animations", ref disableIndividualAnimations)) - { - perms.DisableIndividualAnimations = disableIndividualAnimations; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText("This setting will disable animation sync for all new individual pairs."); - if (ImGui.Checkbox("Disable individual pair VFX", ref disableIndividualVFX)) - { - perms.DisableIndividualVFX = disableIndividualVFX; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText("This setting will disable VFX sync for all new individual pairs."); - ImGuiHelpers.ScaledDummy(5f); - bool disableGroundSounds = perms.DisableGroupSounds; - bool disableGroupAnimations = perms.DisableGroupAnimations; - bool disableGroupVFX = perms.DisableGroupVFX; - if (ImGui.Checkbox("Disable Syncshell pair sounds", ref disableGroundSounds)) - { - perms.DisableGroupSounds = disableGroundSounds; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText( - "This setting will disable sound sync for all non-sticky pairs in newly joined syncshells."); - if (ImGui.Checkbox("Disable Syncshell pair animations", ref disableGroupAnimations)) - { - perms.DisableGroupAnimations = disableGroupAnimations; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText( - "This setting will disable animation sync for all non-sticky pairs in newly joined syncshells."); - if (ImGui.Checkbox("Disable Syncshell pair VFX", ref disableGroupVFX)) - { - perms.DisableGroupVFX = disableGroupVFX; - _ = _apiController.UserUpdateDefaultPermissions(perms); - } - - _uiShared.DrawHelpText( - "This setting will disable VFX sync for all non-sticky pairs in newly joined syncshells."); - } - else - { - UiSharedService.ColorTextWrapped("Default Permission Settings unavailable for this service. " + - "You need to connect to this service to change the default permissions since they are stored on the service.", - UIColors.Get("LightlessYellow")); - } - - ImGui.EndTabItem(); - } - - ImGui.EndTabBar(); + switch (_selectedServerTab) + { + case ServerSettingsTab.CharacterManagement: + DrawServerCharacterManagement(selectedServer, idx, useOauth); + break; + case ServerSettingsTab.SecretKeyManagement when !useOauth: + DrawServerSecretKeyManagement(selectedServer); + break; + case ServerSettingsTab.ServiceConfiguration: + DrawServerServiceConfiguration(selectedServer, ref useOauth); + break; + case ServerSettingsTab.PermissionSettings: + DrawServerPermissionSettings(selectedServer); + break; } ImGui.Dummy(new Vector2(10)); } + private void DrawServerCharacterManagement(ServerStorage selectedServer, int idx, bool useOauth) + { + if (selectedServer.SecretKeys.Any() || useOauth) + { + UiSharedService.ColorTextWrapped( + "Characters listed here will automatically connect to the selected Lightless service with the settings as provided below." + + " Make sure to enter the character names correctly or use the 'Add current character' button at the bottom.", + UIColors.Get("LightlessYellow")); + int i = 0; + _uiShared.DrawUpdateOAuthUIDsButton(selectedServer); + + if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken)) + { + bool hasSetSecretKeysButNoUid = + selectedServer.Authentications.Exists(u => + u.SecretKeyIdx != -1 && string.IsNullOrEmpty(u.UID)); + if (hasSetSecretKeysButNoUid) + { + ImGui.Dummy(new(5f, 5f)); + UiSharedService.TextWrapped( + "Some entries have been detected that have previously been assigned secret keys but not UIDs. " + + "Press this button below to attempt to convert those entries."); + using (ImRaii.Disabled(_secretKeysConversionTask != null && + !_secretKeysConversionTask.IsCompleted)) + { + if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowsLeftRight, + "Try to Convert Secret Keys to UIDs")) + { + _secretKeysConversionTask = + ConvertSecretKeysToUIDs(selectedServer, _secretKeysConversionCts.Token); + } + } + + if (_secretKeysConversionTask != null && !_secretKeysConversionTask.IsCompleted) + { + UiSharedService.ColorTextWrapped("Converting Secret Keys to UIDs", + UIColors.Get("LightlessYellow")); + } + + if (_secretKeysConversionTask != null && _secretKeysConversionTask.IsCompletedSuccessfully) + { + Vector4? textColor = null; + if (_secretKeysConversionTask.Result.PartialSuccess) + { + textColor = UIColors.Get("LightlessYellow"); + } + + if (!_secretKeysConversionTask.Result.Success) + { + textColor = ImGuiColors.DalamudRed; + } + + string text = $"Conversion has completed: {_secretKeysConversionTask.Result.Result}"; + if (textColor == null) + { + UiSharedService.TextWrapped(text); + } + else + { + UiSharedService.ColorTextWrapped(text, textColor!.Value); + } + + if (!_secretKeysConversionTask.Result.Success || + _secretKeysConversionTask.Result.PartialSuccess) + { + UiSharedService.TextWrapped( + "In case of conversion failures, please set the UIDs for the failed conversions manually."); + } + } + } + } + + ImGui.Separator(); + string youName = _dalamudUtilService.GetPlayerName(); + uint youWorld = _dalamudUtilService.GetHomeWorldId(); + ulong youCid = _dalamudUtilService.GetCID(); + if (!selectedServer.Authentications.Exists(a => + string.Equals(a.CharacterName, youName, StringComparison.Ordinal) && a.WorldId == youWorld)) + { + _uiShared.BigText("Your Character is not Configured", ImGuiColors.DalamudRed); + UiSharedService.ColorTextWrapped( + "You have currently no character configured that corresponds to your current name and world.", + ImGuiColors.DalamudRed); + var authWithCid = selectedServer.Authentications.Find(f => f.LastSeenCID == youCid); + if (authWithCid != null) + { + ImGuiHelpers.ScaledDummy(5); + UiSharedService.ColorTextWrapped( + "A potential rename/world change from this character was detected:", + UIColors.Get("LightlessYellow")); + using (ImRaii.PushIndent(10f)) + UiSharedService.ColorText( + "Entry: " + authWithCid.CharacterName + " - " + + _dalamudUtilService.WorldData.Value[(ushort)authWithCid.WorldId], + UIColors.Get("LightlessBlue")); + UiSharedService.ColorText( + "Press the button below to adjust that entry to your current character:", + UIColors.Get("LightlessYellow")); + using (ImRaii.PushIndent(10f)) + UiSharedService.ColorText( + "Current: " + youName + " - " + + _dalamudUtilService.WorldData.Value[(ushort)youWorld], + UIColors.Get("LightlessBlue")); + ImGuiHelpers.ScaledDummy(5); + if (_uiShared.IconTextButton(FontAwesomeIcon.ArrowRight, + "Update Entry to Current Character")) + { + authWithCid.CharacterName = youName; + authWithCid.WorldId = youWorld; + _serverConfigurationManager.Save(); + } + } + + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); + } + + foreach (var item in selectedServer.Authentications.ToList()) + { + using var charaId = ImRaii.PushId("selectedChara" + i); + + var worldIdx = (ushort)item.WorldId; + var data = _uiShared.WorldData.OrderBy(u => u.Value, StringComparer.Ordinal) + .ToDictionary(k => k.Key, k => k.Value); + if (!data.TryGetValue(worldIdx, out string? worldPreview)) + { + worldPreview = data.First().Value; + } + + Dictionary keys = []; + + if (!useOauth) + { + var secretKeyIdx = item.SecretKeyIdx; + keys = selectedServer.SecretKeys; + if (!keys.TryGetValue(secretKeyIdx, out var secretKey)) + { + secretKey = new(); + } + } + + bool thisIsYou = false; + if (string.Equals(youName, item.CharacterName, StringComparison.OrdinalIgnoreCase) + && youWorld == worldIdx) + { + thisIsYou = true; + } + + bool misManaged = false; + if (selectedServer.UseOAuth2 && !string.IsNullOrEmpty(selectedServer.OAuthToken) && + string.IsNullOrEmpty(item.UID)) + { + misManaged = true; + } + + if (!selectedServer.UseOAuth2 && item.SecretKeyIdx == -1) + { + misManaged = true; + } + + Vector4 color = UIColors.Get("LightlessBlue"); + string text = thisIsYou ? "Your Current Character" : string.Empty; + if (misManaged) + { + text += " [MISMANAGED (" + (selectedServer.UseOAuth2 ? "No UID Set" : "No Secret Key Set") + + ")]"; + color = ImGuiColors.DalamudRed; + } + + if (selectedServer.Authentications.Where(e => e != item).Any(e => + string.Equals(e.CharacterName, item.CharacterName, StringComparison.Ordinal) + && e.WorldId == item.WorldId)) + { + text += " [DUPLICATE]"; + color = ImGuiColors.DalamudRed; + } + + if (!string.IsNullOrEmpty(text)) + { + text = text.Trim(); + _uiShared.BigText(text, color); + } + + var charaName = item.CharacterName; + if (ImGui.InputText("Character Name", ref charaName, 64)) + { + item.CharacterName = charaName; + _serverConfigurationManager.Save(); + } + + _uiShared.DrawCombo("World##" + item.CharacterName + i, data, (w) => w.Value, + (w) => + { + if (item.WorldId != w.Key) + { + item.WorldId = w.Key; + _serverConfigurationManager.Save(); + } + }, + EqualityComparer>.Default.Equals( + data.FirstOrDefault(f => f.Key == worldIdx), default) + ? data.First() + : data.First(f => f.Key == worldIdx)); + + if (!useOauth) + { + _uiShared.DrawCombo("Secret Key###" + item.CharacterName + i, keys, + (w) => w.Value.FriendlyName, + (w) => + { + if (w.Key != item.SecretKeyIdx) + { + item.SecretKeyIdx = w.Key; + _serverConfigurationManager.Save(); + } + }, + EqualityComparer>.Default.Equals( + keys.FirstOrDefault(f => f.Key == item.SecretKeyIdx), default) + ? keys.First() + : keys.First(f => f.Key == item.SecretKeyIdx)); + } + else + { + _uiShared.DrawUIDComboForAuthentication(i, item, selectedServer.ServerUri, _logger); + } + + bool isAutoLogin = item.AutoLogin; + if (ImGui.Checkbox("Automatically login to Lightless", ref isAutoLogin)) + { + item.AutoLogin = isAutoLogin; + _serverConfigurationManager.Save(); + } + + _uiShared.DrawHelpText( + "When enabled and logging into this character in XIV, Lightless will automatically connect to the current service."); + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Character") && + UiSharedService.CtrlPressed()) + _serverConfigurationManager.RemoveCharacterFromServer(idx, item); + UiSharedService.AttachToolTip("Hold CTRL to delete this entry."); + + i++; + if (item != selectedServer.Authentications.ToList()[^1]) + { + ImGuiHelpers.ScaledDummy(5); + ImGui.Separator(); + ImGuiHelpers.ScaledDummy(5); + } + } + + if (selectedServer.Authentications.Any()) + ImGui.Separator(); + + if (!selectedServer.Authentications.Exists(c => + string.Equals(c.CharacterName, youName, StringComparison.Ordinal) + && c.WorldId == youWorld)) + { + if (_uiShared.IconTextButton(FontAwesomeIcon.User, "Add current character")) + { + _serverConfigurationManager.AddCurrentCharacterToServer(idx); + } + + ImGui.SameLine(); + } + + if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add new character")) + { + _serverConfigurationManager.AddEmptyCharacterToServer(idx); + } + } + else + { + UiSharedService.ColorTextWrapped("You need to add a Secret Key first before adding Characters.", + UIColors.Get("LightlessYellow")); + } + } + + private void DrawServerSecretKeyManagement(ServerStorage selectedServer) + { + foreach (var item in selectedServer.SecretKeys.ToList()) + { + using var id = ImRaii.PushId("key" + item.Key); + var friendlyName = item.Value.FriendlyName; + if (ImGui.InputText("Secret Key Display Name", ref friendlyName, 255)) + { + item.Value.FriendlyName = friendlyName; + _serverConfigurationManager.Save(); + } + + var key = item.Value.Key; + if (ImGui.InputText("Secret Key", ref key, 64)) + { + item.Value.Key = key; + _serverConfigurationManager.Save(); + } + + if (!selectedServer.Authentications.Exists(p => p.SecretKeyIdx == item.Key)) + { + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Secret Key") && + UiSharedService.CtrlPressed()) + { + selectedServer.SecretKeys.Remove(item.Key); + _serverConfigurationManager.Save(); + } + + UiSharedService.AttachToolTip("Hold CTRL to delete this secret key entry"); + } + else + { + UiSharedService.ColorTextWrapped("This key is in use and cannot be deleted", + UIColors.Get("LightlessYellow")); + } + + if (item.Key != selectedServer.SecretKeys.Keys.LastOrDefault()) + ImGui.Separator(); + } + + ImGui.Separator(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Plus, "Add new Secret Key")) + { + selectedServer.SecretKeys.Add( + selectedServer.SecretKeys.Any() ? selectedServer.SecretKeys.Max(p => p.Key) + 1 : 0, + new SecretKey() { FriendlyName = "New Secret Key", }); + _serverConfigurationManager.Save(); + } + } + + private void DrawServerServiceConfiguration(ServerStorage selectedServer, ref bool useOauth) + { + var serverName = selectedServer.ServerName; + var serverUri = selectedServer.ServerUri; + var isMain = string.Equals(serverName, ApiController.MainServer, StringComparison.OrdinalIgnoreCase); + var flags = isMain ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None; + + if (ImGui.InputText("Service URI", ref serverUri, 255, flags)) + { + selectedServer.ServerUri = serverUri; + } + + if (isMain) + { + _uiShared.DrawHelpText("You cannot edit the URI of the main service."); + } + + if (ImGui.InputText("Service Name", ref serverName, 255, flags)) + { + selectedServer.ServerName = serverName; + _serverConfigurationManager.Save(); + } + + if (isMain) + { + _uiShared.DrawHelpText("You cannot edit the name of the main service."); + } + + ImGui.SetNextItemWidth(200); + var serverTransport = _serverConfigurationManager.GetTransport(); + _uiShared.DrawCombo("Server Transport Type", + Enum.GetValues().Where(t => t != HttpTransportType.None), + (v) => v.ToString(), + onSelected: (t) => _serverConfigurationManager.SetTransportType(t), + serverTransport); + _uiShared.DrawHelpText( + "You normally do not need to change this, if you don't know what this is or what it's for, keep it to WebSockets." + + Environment.NewLine + + "If you run into connection issues with e.g. VPNs, try ServerSentEvents first before trying out LongPolling." + + UiSharedService.TooltipSeparator + + "Note: if the server does not support a specific Transport Type it will fall through to the next automatically: WebSockets > ServerSentEvents > LongPolling"); + + ImGuiHelpers.ScaledDummy(5); + + if (ImGui.Checkbox("Use Discord OAuth2 Authentication", ref useOauth)) + { + selectedServer.UseOAuth2 = useOauth; + _serverConfigurationManager.Save(); + } + + _uiShared.DrawHelpText( + "Use Discord OAuth2 Authentication to identify with this server instead of secret keys"); + if (useOauth) + { + _uiShared.DrawOAuth(selectedServer); + if (string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer))) + { + ImGuiHelpers.ScaledDummy(10f); + UiSharedService.ColorTextWrapped( + "You have enabled OAuth2 but it is not linked. Press the buttons Check, then Authenticate to link properly.", + ImGuiColors.DalamudRed); + } + + if (!string.IsNullOrEmpty(_serverConfigurationManager.GetDiscordUserFromToken(selectedServer)) + && selectedServer.Authentications.TrueForAll(u => string.IsNullOrEmpty(u.UID))) + { + ImGuiHelpers.ScaledDummy(10f); + UiSharedService.ColorTextWrapped( + "You have enabled OAuth2 but no characters configured. Set the correct UIDs for your characters in \"Character Management\".", + ImGuiColors.DalamudRed); + } + } + + if (!isMain && selectedServer != _serverConfigurationManager.CurrentServer) + { + ImGui.Separator(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Trash, "Delete Service") && + UiSharedService.CtrlPressed()) + { + _serverConfigurationManager.DeleteServer(selectedServer); + } + + _uiShared.DrawHelpText("Hold CTRL to delete this service"); + } + } + + private void DrawServerPermissionSettings(ServerStorage selectedServer) + { + _uiShared.BigText("Default Permission Settings"); + if (selectedServer == _serverConfigurationManager.CurrentServer && _apiController.IsConnected) + { + UiSharedService.TextWrapped( + "Note: The default permissions settings here are not applied retroactively to existing pairs or joined Syncshells."); + UiSharedService.TextWrapped( + "Note: The default permissions settings here are sent and stored on the connected service."); + ImGuiHelpers.ScaledDummy(5f); + var perms = _apiController.DefaultPermissions!; + bool individualIsSticky = perms.IndividualIsSticky; + bool disableIndividualSounds = perms.DisableIndividualSounds; + bool disableIndividualAnimations = perms.DisableIndividualAnimations; + bool disableIndividualVFX = perms.DisableIndividualVFX; + if (ImGui.Checkbox("Individually set permissions become preferred permissions", + ref individualIsSticky)) + { + perms.IndividualIsSticky = individualIsSticky; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText( + "The preferred attribute means that the permissions to that user will never change through any of your permission changes to Syncshells " + + "(i.e. if you have paused one specific user in a Syncshell and they become preferred permissions, then pause and unpause the same Syncshell, the user will remain paused - " + + "if a user does not have preferred permissions, it will follow the permissions of the Syncshell and be unpaused)." + + Environment.NewLine + Environment.NewLine + + "This setting means:" + Environment.NewLine + + " - All new individual pairs get their permissions defaulted to preferred permissions." + + Environment.NewLine + + " - All individually set permissions for any pair will also automatically become preferred permissions. This includes pairs in Syncshells." + + Environment.NewLine + Environment.NewLine + + "It is possible to remove or set the preferred permission state for any pair at any time." + + Environment.NewLine + Environment.NewLine + + "If unsure, leave this setting off."); + ImGuiHelpers.ScaledDummy(3f); + + if (ImGui.Checkbox("Disable individual pair sounds", ref disableIndividualSounds)) + { + perms.DisableIndividualSounds = disableIndividualSounds; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText("This setting will disable sound sync for all new individual pairs."); + if (ImGui.Checkbox("Disable individual pair animations", ref disableIndividualAnimations)) + { + perms.DisableIndividualAnimations = disableIndividualAnimations; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText("This setting will disable animation sync for all new individual pairs."); + if (ImGui.Checkbox("Disable individual pair VFX", ref disableIndividualVFX)) + { + perms.DisableIndividualVFX = disableIndividualVFX; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText("This setting will disable VFX sync for all new individual pairs."); + ImGuiHelpers.ScaledDummy(5f); + bool disableGroundSounds = perms.DisableGroupSounds; + bool disableGroupAnimations = perms.DisableGroupAnimations; + bool disableGroupVFX = perms.DisableGroupVFX; + if (ImGui.Checkbox("Disable Syncshell pair sounds", ref disableGroundSounds)) + { + perms.DisableGroupSounds = disableGroundSounds; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText( + "This setting will disable sound sync for all non-sticky pairs in newly joined syncshells."); + if (ImGui.Checkbox("Disable Syncshell pair animations", ref disableGroupAnimations)) + { + perms.DisableGroupAnimations = disableGroupAnimations; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText( + "This setting will disable animation sync for all non-sticky pairs in newly joined syncshells."); + if (ImGui.Checkbox("Disable Syncshell pair VFX", ref disableGroupVFX)) + { + perms.DisableGroupVFX = disableGroupVFX; + _ = _apiController.UserUpdateDefaultPermissions(perms); + } + + _uiShared.DrawHelpText( + "This setting will disable VFX sync for all non-sticky pairs in newly joined syncshells."); + } + else + { + UiSharedService.ColorTextWrapped("Default Permission Settings unavailable for this service. " + + "You need to connect to this service to change the default permissions since they are stored on the service.", + UIColors.Get("LightlessYellow")); + } + } + private int _lastSelectedServerIndex = -1; private Task<(bool Success, bool PartialSuccess, string Result)>? _secretKeysConversionTask = null; private CancellationTokenSource _secretKeysConversionCts = new(); @@ -3605,58 +3905,39 @@ public class SettingsUi : WindowMediatorSubscriberBase } ImGui.Separator(); - if (ImGui.BeginTabBar("mainTabBar")) + + if (_selectGeneralTabOnNextDraw) { - var generalTabFlags = ImGuiTabItemFlags.None; - if (_selectGeneralTabOnNextDraw) - { - generalTabFlags |= ImGuiTabItemFlags.SetSelected; - } + _selectedMainTab = MainSettingsTab.General; + _selectGeneralTabOnNextDraw = false; + } - if (ImGui.BeginTabItem("General", generalTabFlags)) - { - _selectGeneralTabOnNextDraw = false; + UiSharedService.Tab("MainSettingsTabs", MainTabOptions, ref _selectedMainTab); + ImGuiHelpers.ScaledDummy(5); + + switch (_selectedMainTab) + { + case MainSettingsTab.General: DrawGeneral(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Performance")) - { + break; + case MainSettingsTab.Performance: DrawPerformance(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Storage")) - { + break; + case MainSettingsTab.Storage: DrawFileStorageSettings(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Transfers")) - { + break; + case MainSettingsTab.Transfers: DrawCurrentTransfers(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Service Settings")) - { + break; + case MainSettingsTab.ServiceSettings: DrawServerConfiguration(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Notifications")) - { + break; + case MainSettingsTab.Notifications: DrawNotificationSettings(); - ImGui.EndTabItem(); - } - - if (ImGui.BeginTabItem("Debug")) - { + break; + case MainSettingsTab.Debug: DrawDebug(); - ImGui.EndTabItem(); - } - - ImGui.EndTabBar(); + break; } } @@ -4526,4 +4807,4 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.EndTable(); } } -} \ No newline at end of file +} diff --git a/LightlessSync/UI/UISharedService.cs b/LightlessSync/UI/UISharedService.cs index 537d1bf..2b5431a 100644 --- a/LightlessSync/UI/UISharedService.cs +++ b/LightlessSync/UI/UISharedService.cs @@ -28,8 +28,10 @@ using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR; using Microsoft.Extensions.Logging; using System; +using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Numerics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -191,6 +193,99 @@ public partial class UiSharedService : DisposableMediatorSubscriberBase return addSuffix ? $"{dblSByte:0.00} {suffix[i]}" : $"{dblSByte:0.00}"; } + public readonly struct TabOption + { + public string Label { get; } + public T Value { get; } + public bool Enabled { get; } + + public TabOption(string label, T value, bool enabled = true) + { + Label = label; + Value = value; + Enabled = enabled; + } + } + + public static bool Tab(string id, IReadOnlyList> options, ref T selectedValue) where T : struct + { + if (options.Count == 0) + return false; + + var pushIdValue = string.IsNullOrEmpty(id) + ? $"UiSharedTab_{RuntimeHelpers.GetHashCode(options):X}" + : id; + using var tabId = ImRaii.PushId(pushIdValue); + + var selectedIndex = -1; + for (var i = 0; i < options.Count; i++) + { + if (!EqualityComparer.Default.Equals(options[i].Value, selectedValue)) + continue; + + selectedIndex = i; + break; + } + + if (selectedIndex == -1 || !options[selectedIndex].Enabled) + selectedIndex = GetFirstEnabledTabIndex(options); + + if (selectedIndex == -1) + return false; + + var changed = DrawTabsInternal(options, ref selectedIndex); + selectedValue = options[Math.Clamp(selectedIndex, 0, options.Count - 1)].Value; + return changed; + } + + private static int GetFirstEnabledTabIndex(IReadOnlyList> options) + { + for (var i = 0; i < options.Count; i++) + { + if (options[i].Enabled) + return i; + } + + return -1; + } + + private static bool DrawTabsInternal(IReadOnlyList> options, ref int selectedIndex) + { + selectedIndex = Math.Clamp(selectedIndex, 0, Math.Max(0, options.Count - 1)); + + var style = ImGui.GetStyle(); + var availableWidth = ImGui.GetContentRegionAvail().X; + var spacingX = style.ItemSpacing.X; + var buttonWidth = options.Count > 0 ? Math.Max(1f, (availableWidth - spacingX * (options.Count - 1)) / options.Count) : availableWidth; + var buttonHeight = Math.Max(ImGui.GetFrameHeight() + style.FramePadding.Y, 28f * ImGuiHelpers.GlobalScale); + var changed = false; + + for (var i = 0; i < options.Count; i++) + { + if (i > 0) + ImGui.SameLine(); + + var tab = options[i]; + var isSelected = i == selectedIndex; + + using (ImRaii.Disabled(!tab.Enabled)) + { + using var tabIndexId = ImRaii.PushId(i); + using var selectedButton = isSelected ? ImRaii.PushColor(ImGuiCol.Button, style.Colors[(int)ImGuiCol.TabActive]) : null; + using var selectedHover = isSelected ? ImRaii.PushColor(ImGuiCol.ButtonHovered, style.Colors[(int)ImGuiCol.TabHovered]) : null; + using var selectedActive = isSelected ? ImRaii.PushColor(ImGuiCol.ButtonActive, style.Colors[(int)ImGuiCol.TabActive]) : null; + + if (ImGui.Button(tab.Label, new Vector2(buttonWidth, buttonHeight))) + { + selectedIndex = i; + changed = true; + } + } + } + + return changed; + } + public static void CenterNextWindow(float width, float height, ImGuiCond cond = ImGuiCond.None) { var center = ImGui.GetMainViewport().GetCenter(); -- 2.49.1 From 023ca2013e48b5cb1d2412235ba307e9f9244eb6 Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 2 Dec 2025 13:39:08 +0900 Subject: [PATCH 068/140] biggest fix ever --- LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index c63bf31..e1e58a6 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -374,6 +374,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { _needsCollectionRebuild = true; _forceFullReapply = true; + _forceApplyMods = true; } if (!releaseFromPenumbra || toRelease == Guid.Empty || !_ipcManager.Penumbra.APIAvailable) -- 2.49.1 From 72cd5006dbc09081aae3f063d9c55896e850c7ef Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 2 Dec 2025 06:33:08 +0100 Subject: [PATCH 069/140] Force SHA1 hashing on updated hash files --- LightlessSync/FileCache/FileCacheManager.cs | 3 +- .../Configurations/LightlessConfig.cs | 1 + LightlessSync/UI/DownloadUi.cs | 230 +++++++++++++----- LightlessSync/UI/DtrEntry.cs | 2 +- LightlessSync/UI/EditProfileUi.Group.cs | 7 +- LightlessSync/UI/SettingsUi.cs | 8 + LightlessSync/UI/SyncshellFinderUI.cs | 37 ++- 7 files changed, 214 insertions(+), 74 deletions(-) diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index e8b0cb8..b9bddda 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -449,13 +449,12 @@ public sealed class FileCacheManager : IHostedService _logger.LogTrace("Updating hash for {path}", fileCache.ResolvedFilepath); var oldHash = fileCache.Hash; var prefixedPath = fileCache.PrefixedFilePath; - var algo = Crypto.DetectAlgo(fileCache.ResolvedFilepath); if (computeProperties) { var fi = new FileInfo(fileCache.ResolvedFilepath); fileCache.Size = fi.Length; fileCache.CompressedSize = null; - fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, algo); + fileCache.Hash = Crypto.ComputeFileHash(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1); fileCache.LastModifiedDateTicks = fi.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); } RemoveHashedFile(oldHash, prefixedPath); diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 478db89..e3305a2 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -65,6 +65,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false; public bool ShowTransferBars { get; set; } = true; public bool ShowTransferWindow { get; set; } = false; + public bool ShowPlayerLinesTransferWindow { get; set; } = true; public bool UseNotificationsForDownloads { get; set; } = true; public bool ShowUploading { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true; diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index aff23ba..51111ed 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -24,15 +24,9 @@ public class DownloadUi : WindowMediatorSubscriberBase private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly ConcurrentDictionary _uploadingPlayers = new(); private readonly Dictionary _smoothed = []; - private readonly Dictionary _downloadSpeeds = new(); - - private sealed class DownloadSpeedTracker - { - public long LastBytes; - public double LastTime; - public double SpeedBytesPerSecond; - } + private readonly Dictionary _downloadSpeeds = []; + private byte _transferBoxTransparency = 100; private bool _notificationDismissed = true; private int _lastDownloadStateHash = 0; @@ -160,7 +154,6 @@ public class DownloadUi : WindowMediatorSubscriberBase if (_configService.Current.ShowTransferBars) { - const int transparency = 100; const int dlBarBorder = 3; const float rounding = 6f; var shadowOffset = new Vector2(2, 2); @@ -210,10 +203,10 @@ public class DownloadUi : WindowMediatorSubscriberBase var drawList = ImGui.GetBackgroundDrawList(); //Shadow, background, border, bar background - drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, transparency / 2), rounding + 2); - drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, transparency), rounding + 2); + drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, 100 / 2), rounding + 2); + drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, 100), rounding + 2); drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(ImGuiColors.DalamudGrey), rounding); - drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, transparency), rounding); + drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, 100), rounding); var dlProgressPercent = transferredBytes / (double)totalBytes; var progressEndX = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth); @@ -226,7 +219,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var downloadText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; UiSharedService.DrawOutlinedFont(drawList, downloadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(ImGuiColors.DalamudGrey), - UiSharedService.Color(0, 0, 0, transparency), + UiSharedService.Color(0, 0, 0, 100), 1 ); } @@ -249,7 +242,7 @@ public class DownloadUi : WindowMediatorSubscriberBase var drawList = ImGui.GetBackgroundDrawList(); UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, UiSharedService.Color(ImGuiColors.DalamudYellow), - UiSharedService.Color(0, 0, 0, transparency), + UiSharedService.Color(0, 0, 0, 100), 2 ); } @@ -267,7 +260,6 @@ public class DownloadUi : WindowMediatorSubscriberBase if (!_currentDownloads.Any()) return; - const int transparency = 150; const float padding = 6f; const float spacingY = 2f; const float minBoxWidth = 320f; @@ -279,6 +271,7 @@ public class DownloadUi : WindowMediatorSubscriberBase long totalBytes = 0; long transferredBytes = 0; + // (Name, files done, files total, bytes done, bytes total, speed) var perPlayer = new List<(string Name, int TransferredFiles, int TotalFiles, long TransferredBytes, long TotalBytes, double SpeedBytesPerSecond)>(); foreach (var transfer in _currentDownloads.ToList()) @@ -301,29 +294,11 @@ public class DownloadUi : WindowMediatorSubscriberBase { if (!_downloadSpeeds.TryGetValue(handler, out var tracker)) { - tracker = new DownloadSpeedTracker - { - LastBytes = playerTransferredBytes, - LastTime = now, - SpeedBytesPerSecond = 0 - }; + tracker = new DownloadSpeedTracker(windowSeconds: 3.0); _downloadSpeeds[handler] = tracker; } - var dt = now - tracker.LastTime; - var dBytes = playerTransferredBytes - tracker.LastBytes; - - if (dt > 0.1 && dBytes >= 0) - { - var instant = dBytes / dt; - tracker.SpeedBytesPerSecond = tracker.SpeedBytesPerSecond <= 0 - ? instant - : tracker.SpeedBytesPerSecond * 0.8 + instant * 0.2; - } - - tracker.LastTime = now; - tracker.LastBytes = playerTransferredBytes; - speed = tracker.SpeedBytesPerSecond; + speed = tracker.Update(now, playerTransferredBytes); } perPlayer.Add(( @@ -336,6 +311,7 @@ public class DownloadUi : WindowMediatorSubscriberBase )); } + // Clean speed trackers for players with no active downloads foreach (var handler in _downloadSpeeds.Keys.ToList()) { if (!_currentDownloads.ContainsKey(handler)) @@ -345,24 +321,30 @@ public class DownloadUi : WindowMediatorSubscriberBase if (totalFiles == 0 || totalBytes == 0) return; + // max speed for scale (clamped) + double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0; + if (maxSpeed <= 0) + maxSpeed = 1; + var drawList = ImGui.GetBackgroundDrawList(); var windowPos = ImGui.GetWindowPos(); + // Overall texts var headerText = $"Downloading {transferredFiles}/{totalFiles} files"; var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; var totalSpeed = perPlayer.Sum(p => p.SpeedBytesPerSecond); var speedText = totalSpeed > 0 ? $"{UiSharedService.ByteToString((long)totalSpeed)}/s" - : "Calculating lightspeed..."; + : "Calculating in lightspeed..."; var headerSize = ImGui.CalcTextSize(headerText); var bytesSize = ImGui.CalcTextSize(bytesText); - var speedSize = ImGui.CalcTextSize(speedText); + var totalSpeedSize = ImGui.CalcTextSize(speedText); float contentWidth = headerSize.X; if (bytesSize.X > contentWidth) contentWidth = bytesSize.X; - if (speedSize.X > contentWidth) contentWidth = speedSize.X; + if (totalSpeedSize.X > contentWidth) contentWidth = totalSpeedSize.X; foreach (var p in perPlayer) { @@ -379,60 +361,114 @@ public class DownloadUi : WindowMediatorSubscriberBase contentWidth = lineSize.X; } - var boxWidth = contentWidth + padding * 2; + var lineHeight = ImGui.GetTextLineHeight(); + var globalBarHeight = lineHeight * 0.8f; + var perPlayerBarHeight = lineHeight * 0.4f; + + // Box width + float boxWidth = contentWidth + padding * 2; if (boxWidth < minBoxWidth) boxWidth = minBoxWidth; - var lineHeight = ImGui.GetTextLineHeight(); - var numTextLines = 3 + perPlayer.Count; - var barHeight = lineHeight * 0.8f; - var boxHeight = padding * 3 + barHeight + numTextLines * (lineHeight + spacingY); + float boxHeight = 0; + boxHeight += padding; + boxHeight += globalBarHeight; + boxHeight += padding; - var origin = windowPos; + boxHeight += lineHeight + spacingY; + boxHeight += lineHeight + spacingY; + boxHeight += lineHeight * 1.4f + spacingY; - var boxMin = origin; - var boxMax = origin + new Vector2(boxWidth, boxHeight); + boxHeight += perPlayer.Count * (lineHeight + perPlayerBarHeight + spacingY * 2); + boxHeight += padding; - drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, transparency), 5f); + var boxMin = windowPos; + var boxMax = new Vector2(windowPos.X + boxWidth, windowPos.Y + boxHeight); + + // Background + border + drawList.AddRectFilled(boxMin, boxMax, UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 5f); drawList.AddRect(boxMin, boxMax, UiSharedService.Color(ImGuiColors.DalamudGrey), 5f); - // Progress bar var cursor = boxMin + new Vector2(padding, padding); + var barMin = cursor; - var barMax = new Vector2(boxMin.X + boxWidth - padding, cursor.Y + barHeight); + var barMax = new Vector2(boxMin.X + boxWidth - padding, cursor.Y + globalBarHeight); var progress = (float)transferredBytes / totalBytes; - drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, transparency), 3f); + if (progress < 0f) progress = 0f; + if (progress > 1f) progress = 1f; + + drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, _transferBoxTransparency), 3f); drawList.AddRectFilled(barMin, new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), UiSharedService.Color(UIColors.Get("LightlessPurple")), 3f); cursor.Y = barMax.Y + padding; // Header - UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); cursor.Y += lineHeight + spacingY; // Bytes - UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); + UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); cursor.Y += lineHeight + spacingY; // Total speed WIP - UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, transparency), 1); - cursor.Y += lineHeight * 1.4f; + UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); + cursor.Y += lineHeight * 1.4f + spacingY; - // Per-player lines - foreach (var p in perPlayer.OrderByDescending(p => p.TotalBytes)) + if (_configService.Current.ShowPlayerLinesTransferWindow) { - var playerSpeedText = p.SpeedBytesPerSecond > 0 - ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" - : "-"; + // Per-player lines + var orderedPlayers = perPlayer.OrderByDescending(p => p.TotalBytes).ToList(); - var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + - $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + - $"@ {playerSpeedText}"; + foreach (var p in orderedPlayers) + { + var playerSpeedText = p.SpeedBytesPerSecond > 0 + ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" + : "-"; - UiSharedService.DrawOutlinedFont(drawList, line, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, transparency), 1); + var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + + $"@ {playerSpeedText}"; - cursor.Y += lineHeight + spacingY; + UiSharedService.DrawOutlinedFont( + drawList, + line, + cursor, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + cursor.Y += lineHeight + spacingY; + + var barBgMin = new Vector2(boxMin.X + padding, cursor.Y); + var barBgMax = new Vector2(boxMax.X - padding, cursor.Y + perPlayerBarHeight); + + drawList.AddRectFilled( + barBgMin, + barBgMax, + UiSharedService.Color(40, 40, 40, _transferBoxTransparency), + 3f + ); + + float ratio = 0f; + if (maxSpeed > 0) + ratio = (float)(p.SpeedBytesPerSecond / maxSpeed); + + if (ratio < 0f) ratio = 0f; + if (ratio > 1f) ratio = 1f; + + var fillX = barBgMin.X + (barBgMax.X - barBgMin.X) * ratio; + var barFillMax = new Vector2(fillX, barBgMax.Y); + + drawList.AddRectFilled( + barBgMin, + barFillMax, + UiSharedService.Color(UIColors.Get("LightlessPurple")), + 3f + ); + + cursor.Y += perPlayerBarHeight + spacingY * 2; + } } } @@ -534,4 +570,70 @@ public class DownloadUi : WindowMediatorSubscriberBase } } } + + private sealed class DownloadSpeedTracker + { + private readonly Queue<(double Time, long Bytes)> _samples = new(); + private readonly double _windowSeconds; + + public double SpeedBytesPerSecond { get; private set; } + + public DownloadSpeedTracker(double windowSeconds = 3.0) + { + _windowSeconds = windowSeconds; + } + + public double Update(double now, long totalBytes) + { + if (_samples.Count > 0 && totalBytes < _samples.Last().Bytes) + { + _samples.Clear(); + } + + _samples.Enqueue((now, totalBytes)); + + while (_samples.Count > 0 && now - _samples.Peek().Time > _windowSeconds) + _samples.Dequeue(); + + if (_samples.Count < 2) + { + SpeedBytesPerSecond = 0; + return SpeedBytesPerSecond; + } + + var oldest = _samples.Peek(); + var newest = _samples.Last(); + + var dt = newest.Time - oldest.Time; + if (dt <= 0.0001) + { + SpeedBytesPerSecond = 0; + return SpeedBytesPerSecond; + } + + var dBytes = newest.Bytes - oldest.Bytes; + if (dBytes <= 0) + { + SpeedBytesPerSecond = 0; + return SpeedBytesPerSecond; + } + + + const long minBytesForSpeed = 32 * 1024; + if (dBytes < minBytesForSpeed) + { + + return SpeedBytesPerSecond; + } + + var avg = dBytes / dt; + + const double alpha = 0.3; + SpeedBytesPerSecond = SpeedBytesPerSecond <= 0 + ? avg + : SpeedBytesPerSecond * (1 - alpha) + avg * alpha; + + return SpeedBytesPerSecond; + } + } } \ No newline at end of file diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 834265f..770331e 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -348,7 +348,7 @@ public sealed class DtrEntry : IDisposable, IHostedService try { var cid = _dalamudUtilService.GetCIDAsync().GetAwaiter().GetResult(); - var hashedCid = cid.ToString().GetBlake3Hash(); + var hashedCid = cid.ToString().GetHash256(); _localHashedCid = hashedCid; _localHashedCidFetchedAt = now; return hashedCid; diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index 3d593d8..c2724fb 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -434,8 +434,10 @@ public partial class EditProfileUi try { var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); - await using var stream = new MemoryStream(fileContent); - var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); + var stream = new MemoryStream(fileContent); + await using (stream.ConfigureAwait(false)) + { + var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); if (!IsSupportedImageFormat(format)) { _showProfileImageError = true; @@ -461,6 +463,7 @@ public partial class EditProfileUi _showProfileImageError = false; _queuedProfileImage = fileContent; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); + } } catch (Exception ex) { diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 1820ed0..0f5efac 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -818,11 +818,19 @@ public class SettingsUi : WindowMediatorSubscriberBase $"D = Decompressing download"); if (!_configService.Current.ShowTransferWindow) ImGui.BeginDisabled(); ImGui.Indent(); + bool editTransferWindowPosition = _uiShared.EditTrackerPosition; if (ImGui.Checkbox("Edit Transfer Window position", ref editTransferWindowPosition)) { _uiShared.EditTrackerPosition = editTransferWindowPosition; } + bool showPlayerLinesTransferWindow = _configService.Current.ShowPlayerLinesTransferWindow; + + if (ImGui.Checkbox("Toggle the Player Lines in the Transfer Window", ref showPlayerLinesTransferWindow)) + { + _configService.Current.ShowPlayerLinesTransferWindow = showPlayerLinesTransferWindow; + _configService.Save(); + } ImGui.Unindent(); if (!_configService.Current.ShowTransferWindow) ImGui.EndDisabled(); diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 5d67544..b550b41 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -37,7 +37,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private GroupJoinDto? _joinDto; private GroupJoinInfoDto? _joinInfo; private DefaultPermissionsDto _ownPermissions = null!; - private const bool _useTestSyncshells = false; + private bool _useTestSyncshells = false; private bool _compactView = false; @@ -82,7 +82,15 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase { ImGui.BeginGroup(); _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); + +#if DEBUG + if (ImGui.SmallButton("Show test syncshells")) + { + _useTestSyncshells = !_useTestSyncshells; + _ = Task.Run(async () => await RefreshSyncshellsAsync().ConfigureAwait(false)); + } ImGui.SameLine(); +#endif string checkboxLabel = "Compact view"; float availWidth = ImGui.GetContentRegionAvail().X; @@ -274,11 +282,30 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ? shell.Group.Alias : shell.Group.GID; - _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); + float startX = ImGui.GetCursorPosX(); + float availWidth = ImGui.GetContentRegionAvail().X; + float rightTextW = ImGui.CalcTextSize(broadcasterName).X; - ImGui.TextColored(ImGuiColors.DalamudGrey, "Broadcaster"); + ImGui.BeginGroup(); + + _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + { + //open profile of syncshell + } + + ImGui.SameLine(); + float rightX = startX + availWidth - rightTextW; + var pos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2(rightX, pos.Y + 3f * ImGuiHelpers.GlobalScale)); ImGui.TextUnformatted(broadcasterName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Broadcaster of the syncshell."); + + ImGui.EndGroup(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); @@ -539,7 +566,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ClearSelection(); } - private List BuildTestSyncshells() + private static List BuildTestSyncshells() { var testGroup1 = new GroupData("TEST-ALPHA", "Alpha Shell"); var testGroup2 = new GroupData("TEST-BETA", "Beta Shell"); -- 2.49.1 From 0e076f6290c2fb763cc49f6657b1e41a44b1ad01 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 2 Dec 2025 07:01:25 +0100 Subject: [PATCH 070/140] Forced another sha1 --- LightlessSync/FileCache/FileCacheManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index b9bddda..e2cdc72 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -257,11 +257,12 @@ public sealed class FileCacheManager : IHostedService brokenEntities.Add(fileCache); return; } + var algo = Crypto.DetectAlgo(fileCache.Hash); string computedHash; try { - computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, algo, token).ConfigureAwait(false); + computedHash = await Crypto.ComputeFileHashAsync(fileCache.ResolvedFilepath, Crypto.HashAlgo.Sha1, token).ConfigureAwait(false); } catch (Exception ex) { -- 2.49.1 From 46a8fc72cb96aebb58358c20b50ca3fa5ac244e8 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 2 Dec 2025 18:19:15 +0100 Subject: [PATCH 071/140] Changed profile opening to use GroupData instead of full info, Added opening of syncshell profile from finder. --- LightlessSync/Services/Mediator/Messages.cs | 2 +- LightlessSync/Services/UiFactory.cs | 2 +- LightlessSync/Services/UiService.cs | 2 +- .../UI/Components/DrawFolderGroup.cs | 2 +- LightlessSync/UI/EditProfileUi.Group.cs | 4 +- LightlessSync/UI/EditProfileUi.cs | 4 +- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 2 +- LightlessSync/UI/StandaloneProfileUi.cs | 341 +++++++++--------- LightlessSync/UI/SyncshellAdminUI.cs | 2 +- LightlessSync/UI/SyncshellFinderUI.cs | 10 +- LightlessSync/UI/Tags/ProfileTagService.cs | 4 +- 11 files changed, 192 insertions(+), 183 deletions(-) diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 756874b..33dd062 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -84,7 +84,7 @@ public record PauseMessage(UserData UserData) : MessageBase; public record ProfilePopoutToggle(Pair? Pair) : MessageBase; public record CompactUiChange(Vector2 Size, Vector2 Position) : MessageBase; public record ProfileOpenStandaloneMessage(Pair Pair) : MessageBase; -public record GroupProfileOpenStandaloneMessage(GroupFullInfoDto Group) : MessageBase; +public record GroupProfileOpenStandaloneMessage(GroupData Group) : MessageBase; public record OpenGroupProfileEditorMessage(GroupFullInfoDto Group) : MessageBase; public record CloseGroupProfilePreviewMessage(GroupFullInfoDto Group) : MessageBase; public record ActiveServerChangedMessage(string ServerUrl) : MessageBase; diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 72681f7..7237936 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -113,7 +113,7 @@ public class UiFactory _performanceCollectorService); } - public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupFullInfoDto groupInfo) + public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupData groupInfo) { return new StandaloneProfileUi( _loggerFactory.CreateLogger(), diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index 4951bec..16f0f4f 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -64,7 +64,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase var existingWindow = _createdWindows.Find(p => p is StandaloneProfileUi ui && ui.IsGroupProfile && ui.ProfileGroupData is not null - && string.Equals(ui.ProfileGroupData.GID, msg.Group.Group.GID, StringComparison.Ordinal)); + && string.Equals(ui.ProfileGroupData.GID, msg.Group.GID, StringComparison.Ordinal)); if (existingWindow is StandaloneProfileUi existing) { diff --git a/LightlessSync/UI/Components/DrawFolderGroup.cs b/LightlessSync/UI/Components/DrawFolderGroup.cs index 4e8a6a1..c39326c 100644 --- a/LightlessSync/UI/Components/DrawFolderGroup.cs +++ b/LightlessSync/UI/Components/DrawFolderGroup.cs @@ -91,7 +91,7 @@ public class DrawFolderGroup : DrawFolderBase if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile", menuWidth, true)) { ImGui.CloseCurrentPopup(); - _lightlessMediator.Publish(new GroupProfileOpenStandaloneMessage(_groupFullInfoDto)); + _lightlessMediator.Publish(new GroupProfileOpenStandaloneMessage(_groupFullInfoDto.Group)); } UiSharedService.AttachToolTip("Opens the profile for this syncshell in a new window."); diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index c2724fb..cae1eeb 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -40,7 +40,7 @@ public partial class EditProfileUi var viewport = ImGui.GetMainViewport(); ProfileEditorLayoutCoordinator.Enable(groupInfo.Group.GID); ProfileEditorLayoutCoordinator.EnsureAnchor(viewport.WorkPos, scale); - Mediator.Publish(new GroupProfileOpenStandaloneMessage(groupInfo)); + Mediator.Publish(new GroupProfileOpenStandaloneMessage(groupInfo.Group)); IsOpen = true; _wasOpen = true; @@ -246,7 +246,7 @@ public partial class EditProfileUi ImGui.Dummy(new Vector2(0f, 4f * scale)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); - var savedTags = _profileTagService.ResolveTags(_profileTagIds); + var savedTags = ProfileTagService.ResolveTags(_profileTagIds); if (savedTags.Count == 0) { ImGui.TextDisabled("-- No tags set --"); diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 7c55775..3c9b8ae 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -434,7 +434,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase ImGui.Dummy(new Vector2(0f, 4f * scale)); ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); - var savedTags = _profileTagService.ResolveTags(_profileTagIds); + var savedTags = ProfileTagService.ResolveTags(_profileTagIds); if (savedTags.Count == 0) { ImGui.TextDisabled("-- No tags set --"); @@ -675,7 +675,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase bool sortPayloadBeforeSubmit, Action? onPayloadPrepared = null) { - var tagLibrary = _profileTagService.GetTagLibrary(); + var tagLibrary = ProfileTagService.GetTagLibrary(); if (tagLibrary.Count == 0) { ImGui.TextDisabled("No profile tags are available."); diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index b3f90d0..28f3053 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -91,7 +91,7 @@ public class IdDisplayHandler if (ImGui.IsItemClicked(ImGuiMouseButton.Middle)) { - _mediator.Publish(new GroupProfileOpenStandaloneMessage(group)); + _mediator.Publish(new GroupProfileOpenStandaloneMessage(group.Group)); } } else diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index 2332387..e42bde8 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -24,7 +24,6 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase private readonly ProfileTagService _profileTagService; private readonly UiSharedService _uiSharedService; private readonly UserData? _userData; - private readonly GroupFullInfoDto? _groupInfo; private readonly GroupData? _groupData; private readonly bool _isGroupProfile; private readonly bool _isLightfinderContext; @@ -55,11 +54,11 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase PairUiService pairUiService, Pair? pair, UserData? userData, - GroupFullInfoDto? groupInfo, + GroupData? groupData, bool isLightfinderContext, string? lightfinderCid, PerformanceCollectorService performanceCollector) - : base(logger, mediator, BuildWindowTitle(userData, groupInfo, isLightfinderContext), performanceCollector) + : base(logger, mediator, BuildWindowTitle(userData, groupData, isLightfinderContext), performanceCollector) { _uiSharedService = uiBuilder; _serverManager = serverManager; @@ -68,9 +67,8 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase Pair = pair; _pairUiService = pairUiService; _userData = userData; - _groupInfo = groupInfo; - _groupData = groupInfo?.Group; - _isGroupProfile = groupInfo is not null; + _groupData = groupData; + _isGroupProfile = groupData is not null; _isLightfinderContext = isLightfinderContext; _lightfinderCid = lightfinderCid; @@ -117,12 +115,12 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase return fallback; } - private static string BuildWindowTitle(UserData? userData, GroupFullInfoDto? groupInfo, bool isLightfinderContext) + private static string BuildWindowTitle(UserData? userData, GroupData? groupData, bool isLightfinderContext) { - if (groupInfo is not null) + if (groupData is not null) { - var alias = groupInfo.GroupAliasOrGID; - return $"Syncshell Profile of {alias}##LightlessSyncStandaloneGroupProfileUI{groupInfo.Group.GID}"; + var alias = groupData.AliasOrGID; + return $"Syncshell Profile of {alias}##LightlessSyncStandaloneGroupProfileUI{groupData.GID}"; } if (userData is null) @@ -185,7 +183,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase var profile = _lightlessProfileManager.GetLightlessProfile(userData); IReadOnlyList profileTags = profile.Tags.Count > 0 - ? _profileTagService.ResolveTags(profile.Tags) + ? ProfileTagService.ResolveTags(profile.Tags) : Array.Empty(); if (_textureWrap == null || !profile.ImageData.Value.SequenceEqual(_lastProfilePicture)) @@ -705,7 +703,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase private void DrawGroupProfileWindow() { - if (_groupInfo is null || _groupData is null) + if (_groupData is null) return; var scale = ImGuiHelpers.GlobalScale; @@ -745,7 +743,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase var profile = _lightlessProfileManager.GetLightlessGroupProfile(_groupData); IReadOnlyList profileTags = profile.Tags.Count > 0 - ? _profileTagService.ResolveTags(profile.Tags) + ? ProfileTagService.ResolveTags(profile.Tags) : Array.Empty(); if (_textureWrap == null || !profile.ProfileImageData.Value.SequenceEqual(_lastProfilePicture)) @@ -787,8 +785,8 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase int memberCount = 0; List? groupMembers = null; var snapshot = _pairUiService.GetSnapshot(); - var groupInfo = _groupInfo; - if (groupInfo is not null && snapshot.GroupsByGid.TryGetValue(groupInfo.GID, out var refreshedGroupInfo)) + GroupFullInfoDto groupInfo = null; + if (_groupData is not null && snapshot.GroupsByGid.TryGetValue(_groupData.GID, out var refreshedGroupInfo)) { groupInfo = refreshedGroupInfo; } @@ -912,172 +910,175 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase bool useVanityColors = false; Vector4? vanityTextColor = null; Vector4? vanityGlowColor = null; - string primaryHeaderText = _groupInfo.GroupAliasOrGID; - - List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = new() + if (_groupData is not null && groupInfo is not null) { + string primaryHeaderText = _groupData.AliasOrGID; + + List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = + [ (_groupData.GID, false, true) - }; + ]; - if (_groupInfo.Owner is not null) - secondaryHeaderLines.Add(($"Owner: {_groupInfo.Owner.AliasOrUID}", false, true)); + if (groupInfo.Owner is not null) + secondaryHeaderLines.Add(($"Owner: {groupInfo.Owner.AliasOrUID}", false, true)); - var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); - var aliasColumnX = infoOffsetX + 18f * scale; - ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); + var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); + var aliasColumnX = infoOffsetX + 18f * scale; + ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); - ImGui.BeginGroup(); - using (_uiSharedService.UidFont.Push()) - { - ImGui.TextUnformatted(primaryHeaderText); - } - - foreach (var (text, useColor, disabled) in secondaryHeaderLines) - { - if (useColor && useVanityColors) + ImGui.BeginGroup(); + using (_uiSharedService.UidFont.Push()) { - var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); - SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + ImGui.TextUnformatted(primaryHeaderText); + } + + foreach (var (text, useColor, disabled) in secondaryHeaderLines) + { + if (useColor && useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); + SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + } + else + { + if (disabled) + ImGui.TextDisabled(text); + else + ImGui.TextUnformatted(text); + } + } + ImGui.EndGroup(); + var namesEnd = ImGui.GetCursorPos(); + + var aliasGroupRectMin = ImGui.GetItemRectMin(); + var aliasGroupRectMax = ImGui.GetItemRectMax(); + var aliasGroupLocalMin = aliasGroupRectMin - windowPos; + var aliasGroupLocalMax = aliasGroupRectMax - windowPos; + + var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); + ImGui.SetCursorPos(tagsStartLocal); + if (profileTags.Count > 0) + RenderProfileTags(profileTags, scale); + else + ImGui.TextDisabled("-- No tags set --"); + var tagsEndLocal = ImGui.GetCursorPos(); + var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; + var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; + var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); + var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); + + var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.35f; + var descriptionSeparatorThickness = MathF.Max(1f, scale); + var descriptionExtraOffset = groupInfo.Owner is not null ? style.ItemSpacing.Y * 0.6f : 0f; + var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionSeparatorSpacing + descriptionExtraOffset); + var horizontalInset = style.ItemSpacing.X * 0.5f; + var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); + var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); + drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); + + var descriptionContentStartLocal = new Vector2(aliasColumnX, descriptionStartLocal.Y + descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); + ImGui.SetCursorPos(descriptionContentStartLocal); + ImGui.TextDisabled("Description"); + ImGui.SetCursorPosX(aliasColumnX); + var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; + if (descriptionRegionWidth <= 0f) + descriptionRegionWidth = 1f; + var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); + var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); + float descriptionContentHeight; + float lineHeightWithSpacing; + using (_uiSharedService.GameFont.Push()) + { + lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var measurementText = hasDescription + ? NormalizeDescriptionForMeasurement(profile.Description!) + : GroupDescriptionPlaceholder; + if (string.IsNullOrWhiteSpace(measurementText)) + measurementText = GroupDescriptionPlaceholder; + + descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; + if (descriptionContentHeight <= 0f) + descriptionContentHeight = lineHeightWithSpacing; + } + + var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; + var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); + + RenderDescriptionChild( + "##StandaloneGroupDescription", + new Vector2(descriptionRegionWidth, descriptionChildHeight), + hasDescription ? profile.Description : null, + GroupDescriptionPlaceholder); + var descriptionEndLocal = ImGui.GetCursorPos(); + var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; + aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); + aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); + + var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; + var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); + var presenceStartLocal = new Vector2(portraitFrameLocalMin.X, presenceAnchorY + presenceLabelSpacing); + ImGui.SetCursorPos(presenceStartLocal); + ImGui.TextDisabled("Presence"); + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + if (presenceTokens.Count > 0) + { + var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); + RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); } else { - if (disabled) - ImGui.TextDisabled(text); - else - ImGui.TextUnformatted(text); + ImGui.TextDisabled("-- No status flags --"); + ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); } + + var presenceContentEnd = ImGui.GetCursorPos(); + var separatorSpacing = style.ItemSpacing.Y * 0.2f; + var separatorThickness = MathF.Max(1f, scale); + var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); + var separatorStart = windowPos + separatorStartLocal; + var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); + drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); + var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); + + var columnStartLocalY = afterSeparatorLocal.Y; + var leftColumnX = portraitFrameLocalMin.X; + ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); + float leftColumnEndY = columnStartLocalY; + + if (!string.IsNullOrEmpty(noteText)) + { + ImGui.TextDisabled("Notes"); + ImGui.SetCursorPosX(leftColumnX); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + ImGui.TextUnformatted(noteText); + ImGui.PopTextWrapPos(); + ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); + leftColumnEndY = ImGui.GetCursorPosY(); + } + + leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); + + var columnsBottomLocal = leftColumnEndY; + var columnsBottom = windowPos.Y + columnsBottomLocal; + var topAreaBase = windowPos.Y + topAreaStart.Y; + var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); + var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); + var topAreaHeight = leftBlockBottom - topAreaBase; + if (topAreaHeight < 0f) + topAreaHeight = 0f; + + ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); + + var finalCursorY = ImGui.GetCursorPosY(); + var paddingY = ImGui.GetStyle().WindowPadding.Y; + var computedHeight = finalCursorY + paddingY; + var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); + _lastComputedWindowHeight = adjustedHeight; + + var finalSize = new Vector2(baseWidth, adjustedHeight); + Size = finalSize; + ImGui.SetWindowSize(finalSize, ImGuiCond.Always); } - ImGui.EndGroup(); - var namesEnd = ImGui.GetCursorPos(); - - var aliasGroupRectMin = ImGui.GetItemRectMin(); - var aliasGroupRectMax = ImGui.GetItemRectMax(); - var aliasGroupLocalMin = aliasGroupRectMin - windowPos; - var aliasGroupLocalMax = aliasGroupRectMax - windowPos; - - var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); - ImGui.SetCursorPos(tagsStartLocal); - if (profileTags.Count > 0) - RenderProfileTags(profileTags, scale); - else - ImGui.TextDisabled("-- No tags set --"); - var tagsEndLocal = ImGui.GetCursorPos(); - var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; - var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; - var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); - var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); - - var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.35f; - var descriptionSeparatorThickness = MathF.Max(1f, scale); - var descriptionExtraOffset = _groupInfo.Owner is not null ? style.ItemSpacing.Y * 0.6f : 0f; - var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionSeparatorSpacing + descriptionExtraOffset); - var horizontalInset = style.ItemSpacing.X * 0.5f; - var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); - var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); - drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); - - var descriptionContentStartLocal = new Vector2(aliasColumnX, descriptionStartLocal.Y + descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); - ImGui.SetCursorPos(descriptionContentStartLocal); - ImGui.TextDisabled("Description"); - ImGui.SetCursorPosX(aliasColumnX); - var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; - if (descriptionRegionWidth <= 0f) - descriptionRegionWidth = 1f; - var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); - var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); - float descriptionContentHeight; - float lineHeightWithSpacing; - using (_uiSharedService.GameFont.Push()) - { - lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); - var measurementText = hasDescription - ? NormalizeDescriptionForMeasurement(profile.Description!) - : GroupDescriptionPlaceholder; - if (string.IsNullOrWhiteSpace(measurementText)) - measurementText = GroupDescriptionPlaceholder; - - descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; - if (descriptionContentHeight <= 0f) - descriptionContentHeight = lineHeightWithSpacing; - } - - var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; - var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); - - RenderDescriptionChild( - "##StandaloneGroupDescription", - new Vector2(descriptionRegionWidth, descriptionChildHeight), - hasDescription ? profile.Description : null, - GroupDescriptionPlaceholder); - var descriptionEndLocal = ImGui.GetCursorPos(); - var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; - aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); - aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); - - var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; - var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); - var presenceStartLocal = new Vector2(portraitFrameLocalMin.X, presenceAnchorY + presenceLabelSpacing); - ImGui.SetCursorPos(presenceStartLocal); - ImGui.TextDisabled("Presence"); - ImGui.SetCursorPosX(portraitFrameLocalMin.X); - if (presenceTokens.Count > 0) - { - var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); - RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); - } - else - { - ImGui.TextDisabled("-- No status flags --"); - ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); - } - - var presenceContentEnd = ImGui.GetCursorPos(); - var separatorSpacing = style.ItemSpacing.Y * 0.2f; - var separatorThickness = MathF.Max(1f, scale); - var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); - var separatorStart = windowPos + separatorStartLocal; - var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); - drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); - var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); - - var columnStartLocalY = afterSeparatorLocal.Y; - var leftColumnX = portraitFrameLocalMin.X; - ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); - float leftColumnEndY = columnStartLocalY; - - if (!string.IsNullOrEmpty(noteText)) - { - ImGui.TextDisabled("Notes"); - ImGui.SetCursorPosX(leftColumnX); - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); - ImGui.TextUnformatted(noteText); - ImGui.PopTextWrapPos(); - ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); - leftColumnEndY = ImGui.GetCursorPosY(); - } - - leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); - - var columnsBottomLocal = leftColumnEndY; - var columnsBottom = windowPos.Y + columnsBottomLocal; - var topAreaBase = windowPos.Y + topAreaStart.Y; - var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); - var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); - var topAreaHeight = leftBlockBottom - topAreaBase; - if (topAreaHeight < 0f) - topAreaHeight = 0f; - - ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); - - var finalCursorY = ImGui.GetCursorPosY(); - var paddingY = ImGui.GetStyle().WindowPadding.Y; - var computedHeight = finalCursorY + paddingY; - var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); - _lastComputedWindowHeight = adjustedHeight; - - var finalSize = new Vector2(baseWidth, adjustedHeight); - Size = finalSize; - ImGui.SetWindowSize(finalSize, ImGuiCond.Always); } private IDalamudTextureWrap? GetBannerTexture(byte[] bannerBytes) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index e190193..0eef2e5 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -235,7 +235,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (_uiSharedService.IconTextButton(FontAwesomeIcon.AddressCard, "Open Syncshell Profile")) { - Mediator.Publish(new GroupProfileOpenStandaloneMessage(GroupFullInfo)); + Mediator.Publish(new GroupProfileOpenStandaloneMessage(GroupFullInfo.Group)); } UiSharedService.AttachToolTip("Opens the standalone Syncshell profile window for this group."); diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index b550b41..310d79e 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -220,11 +220,19 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float rightTxtW = ImGui.CalcTextSize(broadcasterName).X; _uiSharedService.MediumText(displayName, UIColors.Get("LightlessPurple")); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Click to open profile."); + if (ImGui.IsItemClicked()) + { + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); + } float rightX = startX + regionW - rightTxtW - style.ItemSpacing.X; ImGui.SameLine(); ImGui.SetCursorPosX(rightX); ImGui.TextUnformatted(broadcasterName); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip("Broadcaster of the syncshell."); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); @@ -293,7 +301,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGui.SetTooltip("Click to open profile."); if (ImGui.IsItemClicked()) { - //open profile of syncshell + Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); } ImGui.SameLine(); diff --git a/LightlessSync/UI/Tags/ProfileTagService.cs b/LightlessSync/UI/Tags/ProfileTagService.cs index 6f9a3ff..45f18dd 100644 --- a/LightlessSync/UI/Tags/ProfileTagService.cs +++ b/LightlessSync/UI/Tags/ProfileTagService.cs @@ -9,10 +9,10 @@ public sealed class ProfileTagService { private static readonly IReadOnlyDictionary TagLibrary = CreateTagLibrary(); - public IReadOnlyDictionary GetTagLibrary() + public static IReadOnlyDictionary GetTagLibrary() => TagLibrary; - public IReadOnlyList ResolveTags(IReadOnlyList? tagIds) + public static IReadOnlyList ResolveTags(IReadOnlyList? tagIds) { if (tagIds is null || tagIds.Count == 0) return Array.Empty(); -- 2.49.1 From 6734021b8976e7f9daaa29751a4307573dc31811 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 2 Dec 2025 18:20:59 +0100 Subject: [PATCH 072/140] GroupFullinfo be nullable --- LightlessSync/UI/StandaloneProfileUi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index e42bde8..f1e782d 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -785,7 +785,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase int memberCount = 0; List? groupMembers = null; var snapshot = _pairUiService.GetSnapshot(); - GroupFullInfoDto groupInfo = null; + GroupFullInfoDto? groupInfo = null; if (_groupData is not null && snapshot.GroupsByGid.TryGetValue(_groupData.GID, out var refreshedGroupInfo)) { groupInfo = refreshedGroupInfo; -- 2.49.1 From c335489ceefd89795f906c99a00901262885d6a6 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 2 Dec 2025 18:41:10 +0100 Subject: [PATCH 073/140] Fixed null errors --- LightlessSync/UI/StandaloneProfileUi.cs | 335 ++++++++++++------------ 1 file changed, 168 insertions(+), 167 deletions(-) diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index f1e782d..f2e805e 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -910,175 +910,176 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase bool useVanityColors = false; Vector4? vanityTextColor = null; Vector4? vanityGlowColor = null; - if (_groupData is not null && groupInfo is not null) + string primaryHeaderText = _groupData.AliasOrGID; + + List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = + [ + (_groupData.GID, false, true) + ]; + + if (groupInfo is not null) + secondaryHeaderLines.Add(($"Owner: {groupInfo.Owner.AliasOrUID}", false, true)); + else + secondaryHeaderLines.Add(($"Unknown Owner", false, true)); + + var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); + var aliasColumnX = infoOffsetX + 18f * scale; + ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); + + ImGui.BeginGroup(); + using (_uiSharedService.UidFont.Push()) { - string primaryHeaderText = _groupData.AliasOrGID; - - List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = - [ - (_groupData.GID, false, true) - ]; - - if (groupInfo.Owner is not null) - secondaryHeaderLines.Add(($"Owner: {groupInfo.Owner.AliasOrUID}", false, true)); - - var infoStartY = MathF.Max(contentStartY, bannerHeight + style.WindowPadding.Y); - var aliasColumnX = infoOffsetX + 18f * scale; - ImGui.SetCursorPos(new Vector2(aliasColumnX, infoStartY)); - - ImGui.BeginGroup(); - using (_uiSharedService.UidFont.Push()) - { - ImGui.TextUnformatted(primaryHeaderText); - } - - foreach (var (text, useColor, disabled) in secondaryHeaderLines) - { - if (useColor && useVanityColors) - { - var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); - SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); - } - else - { - if (disabled) - ImGui.TextDisabled(text); - else - ImGui.TextUnformatted(text); - } - } - ImGui.EndGroup(); - var namesEnd = ImGui.GetCursorPos(); - - var aliasGroupRectMin = ImGui.GetItemRectMin(); - var aliasGroupRectMax = ImGui.GetItemRectMax(); - var aliasGroupLocalMin = aliasGroupRectMin - windowPos; - var aliasGroupLocalMax = aliasGroupRectMax - windowPos; - - var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); - ImGui.SetCursorPos(tagsStartLocal); - if (profileTags.Count > 0) - RenderProfileTags(profileTags, scale); - else - ImGui.TextDisabled("-- No tags set --"); - var tagsEndLocal = ImGui.GetCursorPos(); - var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; - var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; - var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); - var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); - - var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.35f; - var descriptionSeparatorThickness = MathF.Max(1f, scale); - var descriptionExtraOffset = groupInfo.Owner is not null ? style.ItemSpacing.Y * 0.6f : 0f; - var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionSeparatorSpacing + descriptionExtraOffset); - var horizontalInset = style.ItemSpacing.X * 0.5f; - var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); - var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); - drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); - - var descriptionContentStartLocal = new Vector2(aliasColumnX, descriptionStartLocal.Y + descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); - ImGui.SetCursorPos(descriptionContentStartLocal); - ImGui.TextDisabled("Description"); - ImGui.SetCursorPosX(aliasColumnX); - var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; - if (descriptionRegionWidth <= 0f) - descriptionRegionWidth = 1f; - var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); - var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); - float descriptionContentHeight; - float lineHeightWithSpacing; - using (_uiSharedService.GameFont.Push()) - { - lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); - var measurementText = hasDescription - ? NormalizeDescriptionForMeasurement(profile.Description!) - : GroupDescriptionPlaceholder; - if (string.IsNullOrWhiteSpace(measurementText)) - measurementText = GroupDescriptionPlaceholder; - - descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; - if (descriptionContentHeight <= 0f) - descriptionContentHeight = lineHeightWithSpacing; - } - - var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; - var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); - - RenderDescriptionChild( - "##StandaloneGroupDescription", - new Vector2(descriptionRegionWidth, descriptionChildHeight), - hasDescription ? profile.Description : null, - GroupDescriptionPlaceholder); - var descriptionEndLocal = ImGui.GetCursorPos(); - var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; - aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); - aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); - - var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; - var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); - var presenceStartLocal = new Vector2(portraitFrameLocalMin.X, presenceAnchorY + presenceLabelSpacing); - ImGui.SetCursorPos(presenceStartLocal); - ImGui.TextDisabled("Presence"); - ImGui.SetCursorPosX(portraitFrameLocalMin.X); - if (presenceTokens.Count > 0) - { - var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); - RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); - } - else - { - ImGui.TextDisabled("-- No status flags --"); - ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); - } - - var presenceContentEnd = ImGui.GetCursorPos(); - var separatorSpacing = style.ItemSpacing.Y * 0.2f; - var separatorThickness = MathF.Max(1f, scale); - var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); - var separatorStart = windowPos + separatorStartLocal; - var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); - drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); - var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); - - var columnStartLocalY = afterSeparatorLocal.Y; - var leftColumnX = portraitFrameLocalMin.X; - ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); - float leftColumnEndY = columnStartLocalY; - - if (!string.IsNullOrEmpty(noteText)) - { - ImGui.TextDisabled("Notes"); - ImGui.SetCursorPosX(leftColumnX); - ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); - ImGui.TextUnformatted(noteText); - ImGui.PopTextWrapPos(); - ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); - leftColumnEndY = ImGui.GetCursorPosY(); - } - - leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); - - var columnsBottomLocal = leftColumnEndY; - var columnsBottom = windowPos.Y + columnsBottomLocal; - var topAreaBase = windowPos.Y + topAreaStart.Y; - var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); - var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); - var topAreaHeight = leftBlockBottom - topAreaBase; - if (topAreaHeight < 0f) - topAreaHeight = 0f; - - ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); - - var finalCursorY = ImGui.GetCursorPosY(); - var paddingY = ImGui.GetStyle().WindowPadding.Y; - var computedHeight = finalCursorY + paddingY; - var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); - _lastComputedWindowHeight = adjustedHeight; - - var finalSize = new Vector2(baseWidth, adjustedHeight); - Size = finalSize; - ImGui.SetWindowSize(finalSize, ImGuiCond.Always); + ImGui.TextUnformatted(primaryHeaderText); } + + foreach (var (text, useColor, disabled) in secondaryHeaderLines) + { + if (useColor && useVanityColors) + { + var seString = SeStringUtils.BuildFormattedPlayerName(text, vanityTextColor, vanityGlowColor); + SeStringUtils.RenderSeStringWithHitbox(seString, ImGui.GetCursorScreenPos(), ImGui.GetFont()); + } + else + { + if (disabled) + ImGui.TextDisabled(text); + else + ImGui.TextUnformatted(text); + } + } + ImGui.EndGroup(); + var namesEnd = ImGui.GetCursorPos(); + + var aliasGroupRectMin = ImGui.GetItemRectMin(); + var aliasGroupRectMax = ImGui.GetItemRectMax(); + var aliasGroupLocalMin = aliasGroupRectMin - windowPos; + var aliasGroupLocalMax = aliasGroupRectMax - windowPos; + + var tagsStartLocal = new Vector2(aliasGroupLocalMax.X + style.ItemSpacing.X + 25f * scale, aliasGroupLocalMin.Y + style.FramePadding.Y + 2f * scale); + ImGui.SetCursorPos(tagsStartLocal); + if (profileTags.Count > 0) + RenderProfileTags(profileTags, scale); + else + ImGui.TextDisabled("-- No tags set --"); + var tagsEndLocal = ImGui.GetCursorPos(); + var tagsBlockBottom = windowPos.Y + tagsEndLocal.Y; + var aliasBlockBottom = windowPos.Y + aliasGroupLocalMax.Y; + var aliasAndTagsBottomLocal = MathF.Max(aliasGroupLocalMax.Y, tagsEndLocal.Y); + var aliasAndTagsBlockBottom = MathF.Max(aliasBlockBottom, tagsBlockBottom); + + var descriptionSeparatorSpacing = style.ItemSpacing.Y * 0.35f; + var descriptionSeparatorThickness = MathF.Max(1f, scale); + var descriptionExtraOffset = 0f; + if (groupInfo?.Owner is not null) + descriptionExtraOffset = style.ItemSpacing.Y * 0.6f; + var descriptionStartLocal = new Vector2(aliasColumnX, aliasAndTagsBottomLocal + descriptionSeparatorSpacing + descriptionExtraOffset); + var horizontalInset = style.ItemSpacing.X * 0.5f; + var descriptionSeparatorStart = windowPos + new Vector2(aliasColumnX - horizontalInset, descriptionStartLocal.Y); + var descriptionSeparatorEnd = new Vector2(windowPos.X + windowSize.X - style.WindowPadding.X + horizontalInset, descriptionSeparatorStart.Y); + drawList.AddLine(descriptionSeparatorStart, descriptionSeparatorEnd, ImGui.GetColorU32(portraitFrameBorder), descriptionSeparatorThickness); + + var descriptionContentStartLocal = new Vector2(aliasColumnX, descriptionStartLocal.Y + descriptionSeparatorThickness + descriptionSeparatorSpacing + style.FramePadding.Y * 0.75f); + ImGui.SetCursorPos(descriptionContentStartLocal); + ImGui.TextDisabled("Description"); + ImGui.SetCursorPosX(aliasColumnX); + var descriptionRegionWidth = ImGui.GetContentRegionAvail().X; + if (descriptionRegionWidth <= 0f) + descriptionRegionWidth = 1f; + var measurementWrapWidth = MathF.Max(1f, descriptionRegionWidth - style.WindowPadding.X * 2f); + var hasDescription = !string.IsNullOrWhiteSpace(profile.Description); + float descriptionContentHeight; + float lineHeightWithSpacing; + using (_uiSharedService.GameFont.Push()) + { + lineHeightWithSpacing = ImGui.GetTextLineHeightWithSpacing(); + var measurementText = hasDescription + ? NormalizeDescriptionForMeasurement(profile.Description!) + : GroupDescriptionPlaceholder; + if (string.IsNullOrWhiteSpace(measurementText)) + measurementText = GroupDescriptionPlaceholder; + + descriptionContentHeight = ImGui.CalcTextSize(measurementText, wrapWidth: measurementWrapWidth).Y; + if (descriptionContentHeight <= 0f) + descriptionContentHeight = lineHeightWithSpacing; + } + + var maxDescriptionHeight = lineHeightWithSpacing * DescriptionMaxVisibleLines; + var descriptionChildHeight = Math.Clamp(descriptionContentHeight, lineHeightWithSpacing, maxDescriptionHeight); + + RenderDescriptionChild( + "##StandaloneGroupDescription", + new Vector2(descriptionRegionWidth, descriptionChildHeight), + hasDescription ? profile.Description : null, + GroupDescriptionPlaceholder); + var descriptionEndLocal = ImGui.GetCursorPos(); + var descriptionBlockBottom = windowPos.Y + descriptionEndLocal.Y; + aliasAndTagsBottomLocal = MathF.Max(aliasAndTagsBottomLocal, descriptionEndLocal.Y); + aliasAndTagsBlockBottom = MathF.Max(aliasAndTagsBlockBottom, descriptionBlockBottom); + + var presenceLabelSpacing = style.ItemSpacing.Y * 0.35f; + var presenceAnchorY = MathF.Max(portraitFrameLocalMax.Y, aliasGroupLocalMax.Y); + var presenceStartLocal = new Vector2(portraitFrameLocalMin.X, presenceAnchorY + presenceLabelSpacing); + ImGui.SetCursorPos(presenceStartLocal); + ImGui.TextDisabled("Presence"); + ImGui.SetCursorPosX(portraitFrameLocalMin.X); + if (presenceTokens.Count > 0) + { + var presenceColumnWidth = MathF.Max(1f, aliasColumnX - portraitFrameLocalMin.X - style.ItemSpacing.X); + RenderPresenceTokens(presenceTokens, scale, presenceColumnWidth); + } + else + { + ImGui.TextDisabled("-- No status flags --"); + ImGui.Dummy(new Vector2(0f, style.ItemSpacing.Y * 0.25f)); + } + + var presenceContentEnd = ImGui.GetCursorPos(); + var separatorSpacing = style.ItemSpacing.Y * 0.2f; + var separatorThickness = MathF.Max(1f, scale); + var separatorStartLocal = new Vector2(portraitFrameLocalMin.X, presenceContentEnd.Y + separatorSpacing); + var separatorStart = windowPos + separatorStartLocal; + var separatorEnd = new Vector2(portraitFrameMax.X, separatorStart.Y); + drawList.AddLine(separatorStart, separatorEnd, ImGui.GetColorU32(portraitFrameBorder), separatorThickness); + var afterSeparatorLocal = separatorStartLocal + new Vector2(0f, separatorThickness + separatorSpacing * 0.75f); + + var columnStartLocalY = afterSeparatorLocal.Y; + var leftColumnX = portraitFrameLocalMin.X; + ImGui.SetCursorPos(new Vector2(leftColumnX, columnStartLocalY)); + float leftColumnEndY = columnStartLocalY; + + if (!string.IsNullOrEmpty(noteText)) + { + ImGui.TextDisabled("Notes"); + ImGui.SetCursorPosX(leftColumnX); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); + ImGui.TextUnformatted(noteText); + ImGui.PopTextWrapPos(); + ImGui.SetCursorPos(new Vector2(leftColumnX, ImGui.GetCursorPosY() + style.ItemSpacing.Y * 0.5f)); + leftColumnEndY = ImGui.GetCursorPosY(); + } + + leftColumnEndY = MathF.Max(leftColumnEndY, ImGui.GetCursorPosY()); + + var columnsBottomLocal = leftColumnEndY; + var columnsBottom = windowPos.Y + columnsBottomLocal; + var topAreaBase = windowPos.Y + topAreaStart.Y; + var contentBlockBottom = MathF.Max(columnsBottom, aliasAndTagsBlockBottom); + var leftBlockBottom = MathF.Max(portraitBlockBottom, contentBlockBottom); + var topAreaHeight = leftBlockBottom - topAreaBase; + if (topAreaHeight < 0f) + topAreaHeight = 0f; + + ImGui.SetCursorPos(new Vector2(leftColumnX, topAreaStart.Y + topAreaHeight + style.ItemSpacing.Y)); + + var finalCursorY = ImGui.GetCursorPosY(); + var paddingY = ImGui.GetStyle().WindowPadding.Y; + var computedHeight = finalCursorY + paddingY; + var adjustedHeight = Math.Clamp(computedHeight, minHeight, maxAllowedHeight); + _lastComputedWindowHeight = adjustedHeight; + + var finalSize = new Vector2(baseWidth, adjustedHeight); + Size = finalSize; + ImGui.SetWindowSize(finalSize, ImGuiCond.Always); } private IDalamudTextureWrap? GetBannerTexture(byte[] bannerBytes) -- 2.49.1 From 1c36db97dc23caff5ebede1d4b3714762cb27fad Mon Sep 17 00:00:00 2001 From: azyges Date: Wed, 3 Dec 2025 13:59:30 +0900 Subject: [PATCH 074/140] show focus target on visibility hover --- LightlessSync/Services/DalamudUtilService.cs | 93 ++++++++++++++++++-- LightlessSync/Services/Mediator/Messages.cs | 1 + LightlessSync/UI/CompactUI.cs | 60 +++++++++++++ LightlessSync/UI/Components/DrawUserPair.cs | 4 + 4 files changed, 149 insertions(+), 9 deletions(-) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 3a2cb94..1bbef90 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -14,6 +14,7 @@ using LightlessSync.Interop; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Factories; using LightlessSync.PlayerData.Handlers; +using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; using LightlessSync.Utils; @@ -41,10 +42,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private readonly ILogger _logger; private readonly IObjectTable _objectTable; private readonly ActorObjectService _actorObjectService; + private readonly ITargetManager _targetManager; private readonly PerformanceCollectorService _performanceCollector; private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly Lazy _pairFactory; + private PairUniqueIdentifier? _FocusPairIdent; + private IGameObject? _FocusOriginalTarget; private uint? _classJobId = 0; private DateTime _delayedFrameworkUpdateCheck = DateTime.UtcNow; private string _lastGlobalBlockPlayer = string.Empty; @@ -68,6 +72,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber _gameData = gameData; _gameConfig = gameConfig; _actorObjectService = actorObjectService; + _targetManager = targetManager; _blockedCharacterHandler = blockedCharacterHandler; Mediator = mediator; _performanceCollector = performanceCollector; @@ -125,20 +130,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber mediator.Subscribe(this, (msg) => { if (clientState.IsPvP) return; - var pair = _pairFactory.Value.Create(msg.Pair.UniqueIdent) ?? msg.Pair; - var name = pair.PlayerName; - if (string.IsNullOrEmpty(name)) return; - if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor)) - return; - var addr = descriptor.Address; - if (addr == nint.Zero) return; + if (!ResolvePairAddress(msg.Pair, out var pair, out var addr)) return; var useFocusTarget = _configService.Current.UseFocusTarget; _ = RunOnFrameworkThread(() => { + var gameObject = CreateGameObject(addr); + if (gameObject is null) return; if (useFocusTarget) - targetManager.FocusTarget = CreateGameObject(addr); + { + _targetManager.FocusTarget = gameObject; + if (_FocusPairIdent.HasValue && _FocusPairIdent.Value.Equals(pair.UniqueIdent)) + { + _FocusOriginalTarget = _targetManager.FocusTarget; + } + } else - targetManager.Target = CreateGameObject(addr); + { + _targetManager.Target = gameObject; + } }).ConfigureAwait(false); }); IsWine = Util.IsWine(); @@ -148,6 +157,61 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber private Lazy RebuildCID() => new(GetCID); public bool IsWine { get; init; } + private bool ResolvePairAddress(Pair pair, out Pair resolvedPair, out nint address) + { + resolvedPair = _pairFactory.Value.Create(pair.UniqueIdent) ?? pair; + address = nint.Zero; + var name = resolvedPair.PlayerName; + if (string.IsNullOrEmpty(name)) return false; + if (!_actorObjectService.TryGetPlayerByName(name, out var descriptor)) + return false; + address = descriptor.Address; + return address != nint.Zero; + } + + public void FocusVisiblePair(Pair pair) + { + if (_clientState.IsPvP) return; + if (!ResolvePairAddress(pair, out var resolvedPair, out var address)) return; + _ = RunOnFrameworkThread(() => FocusPairUnsafe(address, resolvedPair.UniqueIdent)); + } + + public void ReleaseVisiblePairFocus() + { + _ = RunOnFrameworkThread(ReleaseFocusUnsafe); + } + + private void FocusPairUnsafe(nint address, PairUniqueIdentifier pairIdent) + { + var target = CreateGameObject(address); + if (target is null) return; + + if (!_FocusPairIdent.HasValue) + { + _FocusOriginalTarget = _targetManager.FocusTarget; + } + + _targetManager.FocusTarget = target; + _FocusPairIdent = pairIdent; + } + + private void ReleaseFocusUnsafe() + { + if (!_FocusPairIdent.HasValue) + { + return; + } + + var previous = _FocusOriginalTarget; + if (previous != null && !IsObjectPresent(previous)) + { + previous = null; + } + + _targetManager.FocusTarget = previous; + _FocusPairIdent = null; + _FocusOriginalTarget = null; + } public unsafe GameObject* GposeTarget { @@ -505,6 +569,17 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber Mediator.UnsubscribeAll(this); _framework.Update -= FrameworkOnUpdate; + if (_FocusPairIdent.HasValue) + { + if (_framework.IsInFrameworkUpdateThread) + { + ReleaseFocusUnsafe(); + } + else + { + _ = RunOnFrameworkThread(ReleaseFocusUnsafe); + } + } return Task.CompletedTask; } diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index 33dd062..f8250b9 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -103,6 +103,7 @@ public record PairDataChangedMessage : MessageBase; public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase; public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase; public record TargetPairMessage(Pair Pair) : MessageBase; +public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage; public record CombatStartMessage : MessageBase; public record CombatEndMessage : MessageBase; public record PerformanceStartMessage : MessageBase; diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index d2715c9..a40dd1b 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -56,6 +56,7 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly TagHandler _tagHandler; private readonly UiSharedService _uiSharedService; private readonly LightFinderService _broadcastService; + private readonly DalamudUtilService _dalamudUtilService; private List _drawFolders; private Pair? _lastAddedUser; @@ -68,6 +69,9 @@ public class CompactUi : WindowMediatorSubscriberBase private float _windowContentWidth; private readonly SeluneBrush _seluneBrush = new(); private const float _connectButtonHighlightThickness = 14f; + private Pair? _focusedPair; + private Pair? _pendingFocusPair; + private int _pendingFocusFrame = -1; public CompactUi( ILogger logger, @@ -109,7 +113,9 @@ public class CompactUi : WindowMediatorSubscriberBase _ipcManager = ipcManager; _broadcastService = broadcastService; _pairLedger = pairLedger; + _dalamudUtilService = dalamudUtilService; _tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService); + Mediator.Subscribe(this, msg => RegisterFocusCharacter(msg.Pair)); AllowPinning = true; AllowClickthrough = false; @@ -178,6 +184,12 @@ public class CompactUi : WindowMediatorSubscriberBase _lightlessMediator = mediator; } + public override void OnClose() + { + ForceReleaseFocus(); + base.OnClose(); + } + protected override void DrawInternal() { var drawList = ImGui.GetWindowDrawList(); @@ -268,6 +280,8 @@ public class CompactUi : WindowMediatorSubscriberBase selune.Animate(ImGui.GetIO().DeltaTime); } + ProcessFocusTracker(); + var lastAddedPair = _pairUiService.GetLastAddedPair(); if (_configService.Current.OpenPopupOnAdd && lastAddedPair is not null) { @@ -1117,4 +1131,50 @@ public class CompactUi : WindowMediatorSubscriberBase _wasOpen = IsOpen; IsOpen = false; } + + private void RegisterFocusCharacter(Pair pair) + { + _pendingFocusPair = pair; + _pendingFocusFrame = ImGui.GetFrameCount(); + } + + private void ProcessFocusTracker() + { + var frame = ImGui.GetFrameCount(); + Pair? character = _pendingFocusFrame == frame ? _pendingFocusPair : null; + if (!ReferenceEquals(character, _focusedPair)) + { + if (character is null) + { + _dalamudUtilService.ReleaseVisiblePairFocus(); + } + else + { + _dalamudUtilService.FocusVisiblePair(character); + } + + _focusedPair = character; + } + + if (_pendingFocusFrame == frame) + { + _pendingFocusPair = null; + _pendingFocusFrame = -1; + } + } + + private void ForceReleaseFocus() + { + if (_focusedPair is null) + { + _pendingFocusPair = null; + _pendingFocusFrame = -1; + return; + } + + _dalamudUtilService.ReleaseVisiblePairFocus(); + _focusedPair = null; + _pendingFocusPair = null; + _pendingFocusFrame = -1; + } } diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 0c8b649..877fe6d 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -247,6 +247,10 @@ public class DrawUserPair else if (_pair.IsVisible) { _uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue")); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped | ImGuiHoveredFlags.AllowWhenDisabled)) + { + _mediator.Publish(new PairFocusCharacterMessage(_pair)); + } if (ImGui.IsItemClicked()) { _mediator.Publish(new TargetPairMessage(_pair)); -- 2.49.1 From 541d17132dc5138bfdaec66d6c8e1d3208b10a1d Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 5 Dec 2025 10:49:30 +0900 Subject: [PATCH 075/140] performance cache + queued character data application --- .../PlayerData/Pairs/PairCoordinator.cs | 6 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 112 +++++++++++++- .../PlayerData/Pairs/PairHandlerRegistry.cs | 139 +++++++++++++++++- LightlessSync/PlayerData/Pairs/PairLedger.cs | 5 + .../Pairs/PairPerformanceMetricsCache.cs | 65 ++++++++ LightlessSync/Plugin.cs | 5 +- 6 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs diff --git a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs index 7774851..3333eaa 100644 --- a/LightlessSync/PlayerData/Pairs/PairCoordinator.cs +++ b/LightlessSync/PlayerData/Pairs/PairCoordinator.cs @@ -20,6 +20,7 @@ public sealed partial class PairCoordinator : MediatorSubscriberBase private readonly PairManager _pairManager; private readonly PairLedger _pairLedger; private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly PairPerformanceMetricsCache _metricsCache; private readonly ConcurrentDictionary _pendingCharacterData = new(StringComparer.Ordinal); public PairCoordinator( @@ -29,7 +30,8 @@ public sealed partial class PairCoordinator : MediatorSubscriberBase PairHandlerRegistry handlerRegistry, PairManager pairManager, PairLedger pairLedger, - ServerConfigurationManager serverConfigurationManager) + ServerConfigurationManager serverConfigurationManager, + PairPerformanceMetricsCache metricsCache) : base(logger, mediator) { _logger = logger; @@ -39,6 +41,7 @@ public sealed partial class PairCoordinator : MediatorSubscriberBase _pairManager = pairManager; _pairLedger = pairLedger; _serverConfigurationManager = serverConfigurationManager; + _metricsCache = metricsCache; mediator.Subscribe(this, msg => HandleActiveServerChange(msg.ServerUrl)); mediator.Subscribe(this, _ => HandleDisconnected()); @@ -128,6 +131,7 @@ public sealed partial class PairCoordinator : MediatorSubscriberBase _handlerRegistry.ResetAllHandlers(); _pairManager.ClearAll(); _pendingCharacterData.Clear(); + _metricsCache.ClearAll(); _mediator.Publish(new ClearProfileUserDataMessage()); _mediator.Publish(new ClearProfileGroupDataMessage()); PublishPairDataChanged(groupChanged: true); diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index e1e58a6..70f4f0b 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -41,6 +41,7 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject void Initialize(); void ApplyData(CharacterData data); void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); void LoadCachedCharacterData(CharacterData data); void SetUploading(bool uploading); void SetPaused(bool paused); @@ -67,6 +68,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly TextureDownscaleService _textureDownscaleService; private readonly PairStateCache _pairStateCache; + private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly PairManager _pairManager; private CancellationTokenSource? _applicationCancellationTokenSource = new(); private Guid _applicationId; @@ -141,7 +143,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa PairProcessingLimiter pairProcessingLimiter, ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, - PairStateCache pairStateCache) : base(logger, mediator) + PairStateCache pairStateCache, + PairPerformanceMetricsCache performanceMetricsCache) : base(logger, mediator) { _pairManager = pairManager; Ident = ident; @@ -157,6 +160,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _serverConfigManager = serverConfigManager; _textureDownscaleService = textureDownscaleService; _pairStateCache = pairStateCache; + _performanceMetricsCache = performanceMetricsCache; LastAppliedDataBytes = -1; } @@ -493,7 +497,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa LastAppliedApproximateEffectiveVRAMBytes = -1; } - var sanitized = RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone()); + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); if (sanitized is null) { Logger.LogTrace("Sanitized data null for {Ident}", Ident); @@ -513,6 +517,100 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ApplyCharacterData(Guid.NewGuid(), sanitized, shouldForce); } + public bool FetchPerformanceMetricsFromCache() + { + EnsureInitialized(); + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + if (sanitized is null || string.IsNullOrEmpty(dataHash)) + { + return false; + } + + if (!TryApplyCachedMetrics(dataHash)) + { + return false; + } + + _cachedData = sanitized; + _pairStateCache.Store(Ident, sanitized); + return true; + } + + private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash) + { + dataHash = null; + if (LastReceivedCharacterData is null) + { + return null; + } + + var sanitized = RemoveNotSyncedFiles(LastReceivedCharacterData.DeepClone()); + if (sanitized is null) + { + return null; + } + + dataHash = GetDataHashSafe(sanitized); + return sanitized; + } + + private string? GetDataHashSafe(CharacterData data) + { + try + { + return data.DataHash.Value; + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Failed to compute character data hash for {Ident}", Ident); + return null; + } + } + + private bool TryApplyCachedMetrics(string? dataHash) + { + if (string.IsNullOrEmpty(dataHash)) + { + return false; + } + + if (!_performanceMetricsCache.TryGetMetrics(Ident, dataHash, out var metrics)) + { + return false; + } + + ApplyCachedMetrics(metrics); + return true; + } + + private void ApplyCachedMetrics(PairPerformanceMetrics metrics) + { + LastAppliedDataTris = metrics.TriangleCount; + LastAppliedApproximateVRAMBytes = metrics.ApproximateVramBytes; + LastAppliedApproximateEffectiveVRAMBytes = metrics.ApproximateEffectiveVramBytes; + } + + private void StorePerformanceMetrics(CharacterData charaData) + { + if (LastAppliedDataTris < 0 + || LastAppliedApproximateVRAMBytes < 0 + || LastAppliedApproximateEffectiveVRAMBytes < 0) + { + return; + } + + var dataHash = GetDataHashSafe(charaData); + if (string.IsNullOrEmpty(dataHash)) + { + return; + } + + _performanceMetricsCache.StoreMetrics( + Ident, + dataHash, + new PairPerformanceMetrics(LastAppliedDataTris, LastAppliedApproximateVRAMBytes, LastAppliedApproximateEffectiveVRAMBytes)); + } + private bool HasMissingCachedFiles(CharacterData characterData) { try @@ -878,6 +976,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = null; _lastAppliedModdedPaths = null; _needsCollectionRebuild = false; + _performanceMetricsCache.Clear(Ident); Logger.LogDebug("Disposing {name} complete", name); } } @@ -1262,6 +1361,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); } + StorePerformanceMetrics(charaData); Logger.LogDebug("[{applicationId}] Application finished", _applicationId); } catch (OperationCanceledException) @@ -1693,6 +1793,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory private readonly ServerConfigurationManager _serverConfigManager; private readonly TextureDownscaleService _textureDownscaleService; private readonly PairStateCache _pairStateCache; + private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; public PairHandlerAdapterFactory( ILoggerFactory loggerFactory, @@ -1709,7 +1810,8 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory PairProcessingLimiter pairProcessingLimiter, ServerConfigurationManager serverConfigManager, TextureDownscaleService textureDownscaleService, - PairStateCache pairStateCache) + PairStateCache pairStateCache, + PairPerformanceMetricsCache pairPerformanceMetricsCache) { _loggerFactory = loggerFactory; _mediator = mediator; @@ -1726,6 +1828,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _serverConfigManager = serverConfigManager; _textureDownscaleService = textureDownscaleService; _pairStateCache = pairStateCache; + _pairPerformanceMetricsCache = pairPerformanceMetricsCache; } public IPairHandlerAdapter Create(string ident) @@ -1748,6 +1851,7 @@ internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory _pairProcessingLimiter, _serverConfigManager, _textureDownscaleService, - _pairStateCache); + _pairStateCache, + _pairPerformanceMetricsCache); } } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index 97e3733..5421baa 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -10,25 +10,32 @@ namespace LightlessSync.PlayerData.Pairs; public sealed class PairHandlerRegistry : IDisposable { private readonly object _gate = new(); + private readonly object _pendingGate = new(); private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); private readonly Dictionary _entriesByHandler = new(); private readonly IPairHandlerAdapterFactory _handlerFactory; private readonly PairManager _pairManager; private readonly PairStateCache _pairStateCache; + private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; private readonly ILogger _logger; private readonly TimeSpan _deletionGracePeriod = TimeSpan.FromMinutes(5); + private static readonly TimeSpan _handlerReadyTimeout = TimeSpan.FromMinutes(3); + private const int _handlerReadyPollDelayMs = 500; + private readonly Dictionary _pendingCharacterData = new(StringComparer.Ordinal); public PairHandlerRegistry( IPairHandlerAdapterFactory handlerFactory, PairManager pairManager, PairStateCache pairStateCache, + PairPerformanceMetricsCache pairPerformanceMetricsCache, ILogger logger) { _handlerFactory = handlerFactory; _pairManager = pairManager; _pairStateCache = pairStateCache; + _pairPerformanceMetricsCache = pairPerformanceMetricsCache; _logger = logger; } @@ -150,7 +157,8 @@ public sealed class PairHandlerRegistry : IDisposable if (!TryGetHandler(registration.CharacterIdent, out handler) || handler is null) { - return PairOperationResult.Fail($"Handler not ready for {registration.PairIdent.UserId}."); + QueuePendingCharacterData(registration, dto); + return PairOperationResult.Ok(); } } @@ -285,6 +293,8 @@ public sealed class PairHandlerRegistry : IDisposable _entriesByHandler.Clear(); } + CancelAllPendingCharacterData(); + foreach (var handler in handlers) { try @@ -298,6 +308,10 @@ public sealed class PairHandlerRegistry : IDisposable _logger.LogDebug(ex, "Failed to dispose handler for {Ident}", handler.Ident); } } + finally + { + _pairPerformanceMetricsCache.Clear(handler.Ident); + } } } @@ -311,9 +325,12 @@ public sealed class PairHandlerRegistry : IDisposable _entriesByHandler.Clear(); } + CancelAllPendingCharacterData(); + foreach (var handler in handlers) { handler.Dispose(); + _pairPerformanceMetricsCache.Clear(handler.Ident); } } @@ -343,6 +360,7 @@ public sealed class PairHandlerRegistry : IDisposable private bool TryFinalizeHandlerRemoval(IPairHandlerAdapter handler) { + string? ident = null; lock (_gate) { if (!_entriesByHandler.TryGetValue(handler, out var entry) || entry.HasPairs) @@ -351,9 +369,126 @@ public sealed class PairHandlerRegistry : IDisposable return false; } + ident = entry.Ident; _entriesByHandler.Remove(handler); _entriesByIdent.Remove(entry.Ident); - return true; + } + + if (ident is not null) + { + _pairPerformanceMetricsCache.Clear(ident); + CancelPendingCharacterData(ident); + } + + return true; + } + + private void QueuePendingCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) + { + if (registration.CharacterIdent is null) + { + return; + } + + CancellationTokenSource? previous = null; + CancellationTokenSource cts; + lock (_pendingGate) + { + if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous)) + { + previous.Cancel(); + } + + cts = new CancellationTokenSource(); + _pendingCharacterData[registration.CharacterIdent] = cts; + } + + previous?.Dispose(); + cts.CancelAfter(_handlerReadyTimeout); + _ = Task.Run(() => WaitThenApplyPendingCharacterDataAsync(registration, dto, cts.Token, cts)); + } + + private void CancelPendingCharacterData(string ident) + { + CancellationTokenSource? cts = null; + lock (_pendingGate) + { + if (_pendingCharacterData.TryGetValue(ident, out cts)) + { + _pendingCharacterData.Remove(ident); + } + } + + if (cts is not null) + { + cts.Cancel(); + cts.Dispose(); + } + } + + private void CancelAllPendingCharacterData() + { + List? snapshot = null; + lock (_pendingGate) + { + if (_pendingCharacterData.Count > 0) + { + snapshot = _pendingCharacterData.Values.ToList(); + _pendingCharacterData.Clear(); + } + } + + if (snapshot is null) + { + return; + } + + foreach (var cts in snapshot) + { + cts.Cancel(); + cts.Dispose(); + } + } + + private async Task WaitThenApplyPendingCharacterDataAsync( + PairRegistration registration, + OnlineUserCharaDataDto dto, + CancellationToken token, + CancellationTokenSource source) + { + if (registration.CharacterIdent is null) + { + return; + } + + try + { + while (!token.IsCancellationRequested) + { + if (TryGetHandler(registration.CharacterIdent, out var handler) && handler is not null && handler.Initialized) + { + handler.ApplyData(dto.CharaData); + break; + } + + await Task.Delay(_handlerReadyPollDelayMs, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // expected + } + finally + { + lock (_pendingGate) + { + if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out var current) && ReferenceEquals(current, source)) + { + _pendingCharacterData.Remove(registration.CharacterIdent); + } + } + + source.Dispose(); } } } diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index 66decfb..b151e1f 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -263,6 +263,11 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase continue; } + if (handler.FetchPerformanceMetricsFromCache()) + { + continue; + } + try { handler.ApplyLastReceivedData(forced: true); diff --git a/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs b/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs new file mode 100644 index 0000000..110d845 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairPerformanceMetricsCache.cs @@ -0,0 +1,65 @@ +using System.Collections.Concurrent; + +namespace LightlessSync.PlayerData.Pairs; + +public readonly record struct PairPerformanceMetrics( + long TriangleCount, + long ApproximateVramBytes, + long ApproximateEffectiveVramBytes); + +/// +/// caches performance metrics keyed by pair ident +/// +public sealed class PairPerformanceMetricsCache +{ + private sealed record CacheEntry(string DataHash, PairPerformanceMetrics Metrics); + + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + public bool TryGetMetrics(string ident, string dataHash, out PairPerformanceMetrics metrics) + { + metrics = default; + if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash)) + { + return false; + } + + if (!_cache.TryGetValue(ident, out var entry)) + { + return false; + } + + if (!string.Equals(entry.DataHash, dataHash, StringComparison.Ordinal)) + { + return false; + } + + metrics = entry.Metrics; + return true; + } + + public void StoreMetrics(string ident, string dataHash, PairPerformanceMetrics metrics) + { + if (string.IsNullOrEmpty(ident) || string.IsNullOrEmpty(dataHash)) + { + return; + } + + _cache[ident] = new CacheEntry(dataHash, metrics); + } + + public void Clear(string ident) + { + if (string.IsNullOrEmpty(ident)) + { + return; + } + + _cache.TryRemove(ident, out _); + } + + public void ClearAll() + { + _cache.Clear(); + } +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 41d8569..08065d1 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -174,11 +174,13 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), new Lazy(() => s.GetRequiredService()))); collection.AddSingleton(); collection.AddSingleton(); + collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(s => new PairHandlerRegistry( s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), + s.GetRequiredService(), s.GetRequiredService>())); collection.AddSingleton(); collection.AddSingleton(); @@ -201,7 +203,8 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService())); + s.GetRequiredService(), + s.GetRequiredService())); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(addonLifecycle); -- 2.49.1 From 69b504c42ffacfe380b1c0143e78e91278a71366 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 5 Dec 2025 04:43:30 +0100 Subject: [PATCH 076/140] Added the old system of WQPD back. added more options of it. --- .../Configurations/LightlessConfig.cs | 1 + LightlessSync/UI/DownloadUi.cs | 486 +++++++++++++----- LightlessSync/UI/SettingsUi.cs | 155 +----- 3 files changed, 390 insertions(+), 252 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index e3305a2..0b448ad 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -66,6 +66,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowTransferBars { get; set; } = true; public bool ShowTransferWindow { get; set; } = false; public bool ShowPlayerLinesTransferWindow { get; set; } = true; + public bool ShowPlayerSpeedBarsTransferWindow { get; set; } = true; public bool UseNotificationsForDownloads { get; set; } = true; public bool ShowUploading { get; set; } = true; public bool ShowUploadingBigText { get; set; } = true; diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 51111ed..dac49c1 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -1,4 +1,4 @@ -using Dalamud.Bindings.ImGui; +using Dalamud.Bindings.ImGui; using Dalamud.Interface.Colors; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; @@ -154,102 +154,228 @@ public class DownloadUi : WindowMediatorSubscriberBase if (_configService.Current.ShowTransferBars) { - const int dlBarBorder = 3; - const float rounding = 6f; - var shadowOffset = new Vector2(2, 2); + DrawTransferBar(); + } + } - foreach (var transfer in _currentDownloads.ToList()) + private void DrawTransferBar() + { + const int dlBarBorder = 3; + const float rounding = 6f; + var shadowOffset = new Vector2(2, 2); + + foreach (var transfer in _currentDownloads.ToList()) + { + var transferKey = transfer.Key; + var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); + + // If RawPos is zero, remove it from smoothed dictionary + if (rawPos == Vector2.Zero) { - var transferKey = transfer.Key; - var rawPos = _dalamudUtilService.WorldToScreen(transferKey.GetGameObject()); + _smoothed.Remove(transferKey); + continue; + } - //If RawPos is zero, remove it from smoothed dictionary - if (rawPos == Vector2.Zero) + // Smoothing out the movement and fix jitter around the position. + Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) + ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos + : rawPos; + _smoothed[transferKey] = screenPos; + + var totalBytes = transfer.Value.Sum(c => c.Value.TotalBytes); + var transferredBytes = transfer.Value.Sum(c => c.Value.TransferredBytes); + + // Per-player state counts + var dlSlot = 0; + var dlQueue = 0; + var dlProg = 0; + var dlDecomp = 0; + + foreach (var entry in transfer.Value) + { + var fileStatus = entry.Value; + switch (fileStatus.DownloadStatus) { - _smoothed.Remove(transferKey); - continue; - } - - //Smoothing out the movement and fix jitter around the position. - Vector2 screenPos = _smoothed.TryGetValue(transferKey, out var lastPos) - ? (rawPos - lastPos).Length() < 4f ? lastPos : rawPos - : rawPos; - _smoothed[transferKey] = screenPos; - - var totalBytes = transfer.Value.Sum(c => c.Value.TotalBytes); - var transferredBytes = transfer.Value.Sum(c => c.Value.TransferredBytes); - - var maxDlText = $"{UiSharedService.ByteToString(totalBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; - var textSize = _configService.Current.TransferBarsShowText - ? ImGui.CalcTextSize(maxDlText) - : new Vector2(10, 10); - - int dlBarHeight = _configService.Current.TransferBarsHeight > ((int)textSize.Y + 5) - ? _configService.Current.TransferBarsHeight - : (int)textSize.Y + 5; - int dlBarWidth = _configService.Current.TransferBarsWidth > ((int)textSize.X + 10) - ? _configService.Current.TransferBarsWidth - : (int)textSize.X + 10; - - var dlBarStart = new Vector2(screenPos.X - dlBarWidth / 2f, screenPos.Y - dlBarHeight / 2f); - var dlBarEnd = new Vector2(screenPos.X + dlBarWidth / 2f, screenPos.Y + dlBarHeight / 2f); - - // Precompute rects - var outerStart = new Vector2(dlBarStart.X - dlBarBorder - 1, dlBarStart.Y - dlBarBorder - 1); - var outerEnd = new Vector2(dlBarEnd.X + dlBarBorder + 1, dlBarEnd.Y + dlBarBorder + 1); - var borderStart = new Vector2(dlBarStart.X - dlBarBorder, dlBarStart.Y - dlBarBorder); - var borderEnd = new Vector2(dlBarEnd.X + dlBarBorder, dlBarEnd.Y + dlBarBorder); - - var drawList = ImGui.GetBackgroundDrawList(); - - //Shadow, background, border, bar background - drawList.AddRectFilled(outerStart + shadowOffset, outerEnd + shadowOffset, UiSharedService.Color(0, 0, 0, 100 / 2), rounding + 2); - drawList.AddRectFilled(outerStart, outerEnd, UiSharedService.Color(0, 0, 0, 100), rounding + 2); - drawList.AddRectFilled(borderStart, borderEnd, UiSharedService.Color(ImGuiColors.DalamudGrey), rounding); - drawList.AddRectFilled(dlBarStart, dlBarEnd, UiSharedService.Color(0, 0, 0, 100), rounding); - - var dlProgressPercent = transferredBytes / (double)totalBytes; - var progressEndX = dlBarStart.X + (float)(dlProgressPercent * dlBarWidth); - var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); - - drawList.AddRectFilled(dlBarStart, progressEnd, UiSharedService.Color(UIColors.Get("LightlessPurple")), rounding); - - if (_configService.Current.TransferBarsShowText) - { - var downloadText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; - UiSharedService.DrawOutlinedFont(drawList, downloadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, - UiSharedService.Color(ImGuiColors.DalamudGrey), - UiSharedService.Color(0, 0, 0, 100), - 1 - ); + case DownloadStatus.WaitingForSlot: + dlSlot++; + break; + case DownloadStatus.WaitingForQueue: + dlQueue++; + break; + case DownloadStatus.Downloading: + dlProg++; + break; + case DownloadStatus.Decompressing: + dlDecomp++; + break; } } - if (_configService.Current.ShowUploading) + string statusText; + if (dlProg > 0) { - foreach (var player in _uploadingPlayers.Select(p => p.Key).ToList()) + statusText = "Downloading"; + } + else if (dlDecomp > 0 || (totalBytes > 0 && transferredBytes >= totalBytes)) + { + statusText = "Decompressing"; + } + else if (dlQueue > 0) + { + statusText = "Waiting for queue"; + } + else if (dlSlot > 0) + { + statusText = "Waiting for slot"; + } + else + { + statusText = "Waiting"; + } + + var hasValidSize = totalBytes > 0; + + var maxDlText = $"{UiSharedService.ByteToString(totalBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; + var textSize = _configService.Current.TransferBarsShowText + ? ImGui.CalcTextSize(maxDlText) + : new Vector2(10, 10); + + int dlBarHeight = _configService.Current.TransferBarsHeight > ((int)textSize.Y + 5) + ? _configService.Current.TransferBarsHeight + : (int)textSize.Y + 5; + int dlBarWidth = _configService.Current.TransferBarsWidth > ((int)textSize.X + 10) + ? _configService.Current.TransferBarsWidth + : (int)textSize.X + 10; + + var dlBarStart = new Vector2(screenPos.X - dlBarWidth / 2f, screenPos.Y - dlBarHeight / 2f); + var dlBarEnd = new Vector2(screenPos.X + dlBarWidth / 2f, screenPos.Y + dlBarHeight / 2f); + + // Precompute rects + var outerStart = new Vector2(dlBarStart.X - dlBarBorder - 1, dlBarStart.Y - dlBarBorder - 1); + var outerEnd = new Vector2(dlBarEnd.X + dlBarBorder + 1, dlBarEnd.Y + dlBarBorder + 1); + var borderStart = new Vector2(dlBarStart.X - dlBarBorder, dlBarStart.Y - dlBarBorder); + var borderEnd = new Vector2(dlBarEnd.X + dlBarBorder, dlBarEnd.Y + dlBarBorder); + + var drawList = ImGui.GetBackgroundDrawList(); + + drawList.AddRectFilled( + outerStart + shadowOffset, + outerEnd + shadowOffset, + UiSharedService.Color(0, 0, 0, 100 / 2), + rounding + 2 + ); + drawList.AddRectFilled( + outerStart, + outerEnd, + UiSharedService.Color(0, 0, 0, 100), + rounding + 2 + ); + drawList.AddRectFilled( + borderStart, + borderEnd, + UiSharedService.Color(ImGuiColors.DalamudGrey), + rounding + ); + drawList.AddRectFilled( + dlBarStart, + dlBarEnd, + UiSharedService.Color(0, 0, 0, 100), + rounding + ); + + bool showFill = false; + double fillPercent = 0.0; + + if (hasValidSize) + { + if (dlProg > 0) { - var screenPos = _dalamudUtilService.WorldToScreen(player.GetGameObject()); - if (screenPos == Vector2.Zero) continue; + fillPercent = transferredBytes / (double)totalBytes; + showFill = true; + } + else if (dlDecomp > 0 || transferredBytes >= totalBytes) + { + fillPercent = 1.0; + showFill = true; + } + } - try + if (showFill) + { + if (fillPercent < 0) fillPercent = 0; + if (fillPercent > 1) fillPercent = 1; + + var progressEndX = dlBarStart.X + (float)(fillPercent * dlBarWidth); + var progressEnd = new Vector2(progressEndX, dlBarEnd.Y); + + drawList.AddRectFilled( + dlBarStart, + progressEnd, + UiSharedService.Color(UIColors.Get("LightlessPurple")), + rounding + ); + } + + if (_configService.Current.TransferBarsShowText) + { + string downloadText; + + if (dlProg > 0 && hasValidSize) + { + downloadText = + $"{statusText} {UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; + } + else if ((dlDecomp > 0 || transferredBytes >= totalBytes) && hasValidSize) + { + downloadText = "Decompressing"; + } + else + { + // Waiting states + downloadText = statusText; + } + + var actualTextSize = ImGui.CalcTextSize(downloadText); + + UiSharedService.DrawOutlinedFont( + drawList, + downloadText, + screenPos with { - using var _ = _uiShared.UidFont.Push(); - var uploadText = "Uploading"; + X = screenPos.X - actualTextSize.X / 2f - 1, + Y = screenPos.Y - actualTextSize.Y / 2f - 1 + }, + UiSharedService.Color(ImGuiColors.DalamudGrey), + UiSharedService.Color(0, 0, 0, 100), + 1 + ); + } + } - var textSize = ImGui.CalcTextSize(uploadText); + if (_configService.Current.ShowUploading) + { + foreach (var player in _uploadingPlayers.Select(p => p.Key).ToList()) + { + var screenPos = _dalamudUtilService.WorldToScreen(player.GetGameObject()); + if (screenPos == Vector2.Zero) continue; - var drawList = ImGui.GetBackgroundDrawList(); - UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, - UiSharedService.Color(ImGuiColors.DalamudYellow), - UiSharedService.Color(0, 0, 0, 100), - 2 - ); - } - catch - { - _logger.LogDebug("Error drawing upload progress"); - } + try + { + using var _ = _uiShared.UidFont.Push(); + var uploadText = "Uploading"; + + var textSize = ImGui.CalcTextSize(uploadText); + + var drawList = ImGui.GetBackgroundDrawList(); + UiSharedService.DrawOutlinedFont(drawList, uploadText, screenPos with { X = screenPos.X - textSize.X / 2f - 1, Y = screenPos.Y - textSize.Y / 2f - 1 }, + UiSharedService.Color(ImGuiColors.DalamudYellow), + UiSharedService.Color(0, 0, 0, 100), + 2 + ); + } + catch + { + _logger.LogDebug("Error drawing upload progress"); } } } @@ -257,7 +383,7 @@ public class DownloadUi : WindowMediatorSubscriberBase private void DrawDownloadSummaryBox() { - if (!_currentDownloads.Any()) + if (_currentDownloads.IsEmpty) return; const float padding = 6f; @@ -271,10 +397,24 @@ public class DownloadUi : WindowMediatorSubscriberBase long totalBytes = 0; long transferredBytes = 0; - // (Name, files done, files total, bytes done, bytes total, speed) - var perPlayer = new List<(string Name, int TransferredFiles, int TotalFiles, long TransferredBytes, long TotalBytes, double SpeedBytesPerSecond)>(); + var totalDlSlot = 0; + var totalDlQueue = 0; + var totalDlProg = 0; + var totalDlDecomp = 0; - foreach (var transfer in _currentDownloads.ToList()) + var perPlayer = new List<( + string Name, + int TransferredFiles, + int TotalFiles, + long TransferredBytes, + long TotalBytes, + double SpeedBytesPerSecond, + int DlSlot, + int DlQueue, + int DlProg, + int DlDecomp)>(); + + foreach (var transfer in _currentDownloads) { var handler = transfer.Key; var statuses = transfer.Value.Values; @@ -289,6 +429,36 @@ public class DownloadUi : WindowMediatorSubscriberBase totalBytes += playerTotalBytes; transferredBytes += playerTransferredBytes; + // per-player W/Q/P/D + var playerDlSlot = 0; + var playerDlQueue = 0; + var playerDlProg = 0; + var playerDlDecomp = 0; + + foreach (var entry in transfer.Value) + { + var fileStatus = entry.Value; + switch (fileStatus.DownloadStatus) + { + case DownloadStatus.WaitingForSlot: + playerDlSlot++; + totalDlSlot++; + break; + case DownloadStatus.WaitingForQueue: + playerDlQueue++; + totalDlQueue++; + break; + case DownloadStatus.Downloading: + playerDlProg++; + totalDlProg++; + break; + case DownloadStatus.Decompressing: + playerDlDecomp++; + totalDlDecomp++; + break; + } + } + double speed = 0; if (playerTotalBytes > 0) { @@ -307,7 +477,11 @@ public class DownloadUi : WindowMediatorSubscriberBase playerTotalFiles, playerTransferredBytes, playerTotalBytes, - speed + speed, + playerDlSlot, + playerDlQueue, + playerDlProg, + playerDlDecomp )); } @@ -321,7 +495,7 @@ public class DownloadUi : WindowMediatorSubscriberBase if (totalFiles == 0 || totalBytes == 0) return; - // max speed for scale (clamped) + // max speed for per-player bar scale (clamped) double maxSpeed = perPlayer.Count > 0 ? perPlayer.Max(p => p.SpeedBytesPerSecond) : 0; if (maxSpeed <= 0) maxSpeed = 1; @@ -330,8 +504,11 @@ public class DownloadUi : WindowMediatorSubscriberBase var windowPos = ImGui.GetWindowPos(); // Overall texts - var headerText = $"Downloading {transferredFiles}/{totalFiles} files"; - var bytesText = $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; + var headerText = + $"Downloading {transferredFiles}/{totalFiles} files [W:{totalDlSlot}/Q:{totalDlQueue}/P:{totalDlProg}/D:{totalDlDecomp}]"; + + var bytesText = + $"{UiSharedService.ByteToString(transferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(totalBytes)}"; var totalSpeed = perPlayer.Sum(p => p.SpeedBytesPerSecond); var speedText = totalSpeed > 0 @@ -346,19 +523,17 @@ public class DownloadUi : WindowMediatorSubscriberBase if (bytesSize.X > contentWidth) contentWidth = bytesSize.X; if (totalSpeedSize.X > contentWidth) contentWidth = totalSpeedSize.X; - foreach (var p in perPlayer) + if (_configService.Current.ShowPlayerLinesTransferWindow) { - var playerSpeedText = p.SpeedBytesPerSecond > 0 - ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" - : "-"; + foreach (var p in perPlayer) + { + var line = + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; - var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + - $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + - $"@ {playerSpeedText}"; - - var lineSize = ImGui.CalcTextSize(line); - if (lineSize.X > contentWidth) - contentWidth = lineSize.X; + var lineSize = ImGui.CalcTextSize(line); + if (lineSize.X > contentWidth) + contentWidth = lineSize.X; + } } var lineHeight = ImGui.GetTextLineHeight(); @@ -370,16 +545,29 @@ public class DownloadUi : WindowMediatorSubscriberBase if (boxWidth < minBoxWidth) boxWidth = minBoxWidth; + // Box height float boxHeight = 0; - boxHeight += padding; - boxHeight += globalBarHeight; + boxHeight += padding; + boxHeight += globalBarHeight; boxHeight += padding; boxHeight += lineHeight + spacingY; boxHeight += lineHeight + spacingY; - boxHeight += lineHeight * 1.4f + spacingY; + boxHeight += lineHeight * 1.4f + spacingY; + + if (_configService.Current.ShowPlayerLinesTransferWindow) + { + foreach (var p in perPlayer) + { + boxHeight += lineHeight + spacingY; + + if (_configService.Current.ShowPlayerSpeedBarsTransferWindow && p.DlProg > 0) + { + boxHeight += perPlayerBarHeight + spacingY; + } + } + } - boxHeight += perPlayer.Count * (lineHeight + perPlayerBarHeight + spacingY * 2); boxHeight += padding; var boxMin = windowPos; @@ -399,25 +587,50 @@ public class DownloadUi : WindowMediatorSubscriberBase if (progress > 1f) progress = 1f; drawList.AddRectFilled(barMin, barMax, UiSharedService.Color(40, 40, 40, _transferBoxTransparency), 3f); - drawList.AddRectFilled(barMin, new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), UiSharedService.Color(UIColors.Get("LightlessPurple")), 3f); + drawList.AddRectFilled( + barMin, + new Vector2(barMin.X + (barMax.X - barMin.X) * progress, barMax.Y), + UiSharedService.Color(UIColors.Get("LightlessPurple")), + 3f + ); cursor.Y = barMax.Y + padding; // Header - UiSharedService.DrawOutlinedFont(drawList, headerText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); + UiSharedService.DrawOutlinedFont( + drawList, + headerText, + cursor, + UiSharedService.Color(ImGuiColors.DalamudWhite), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); cursor.Y += lineHeight + spacingY; // Bytes - UiSharedService.DrawOutlinedFont(drawList, bytesText, cursor, UiSharedService.Color(ImGuiColors.DalamudWhite), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); + UiSharedService.DrawOutlinedFont( + drawList, + bytesText, + cursor, + UiSharedService.Color(ImGuiColors.DalamudWhite), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); cursor.Y += lineHeight + spacingY; - // Total speed WIP - UiSharedService.DrawOutlinedFont(drawList, speedText, cursor, UiSharedService.Color(UIColors.Get("LightlessPurple")), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1); + // Total speed + UiSharedService.DrawOutlinedFont( + drawList, + speedText, + cursor, + UiSharedService.Color(UIColors.Get("LightlessPurple")), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); cursor.Y += lineHeight * 1.4f + spacingY; if (_configService.Current.ShowPlayerLinesTransferWindow) { - // Per-player lines var orderedPlayers = perPlayer.OrderByDescending(p => p.TotalBytes).ToList(); foreach (var p in orderedPlayers) @@ -426,13 +639,31 @@ public class DownloadUi : WindowMediatorSubscriberBase ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" : "-"; - var line = $"{p.Name}: {p.TransferredFiles}/{p.TotalFiles} " + - $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + - $"@ {playerSpeedText}"; + var labelLine = + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + + if (!_configService.Current.ShowPlayerSpeedBarsTransferWindow || p.DlProg <= 0) + { + var fullLine = + $"{labelLine} " + + $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + + $"@ {playerSpeedText}"; + + UiSharedService.DrawOutlinedFont( + drawList, + fullLine, + cursor, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + cursor.Y += lineHeight + spacingY; + continue; + } UiSharedService.DrawOutlinedFont( drawList, - line, + labelLine, cursor, UiSharedService.Color(255, 255, 255, _transferBoxTransparency), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), @@ -467,7 +698,26 @@ public class DownloadUi : WindowMediatorSubscriberBase 3f ); - cursor.Y += perPlayerBarHeight + spacingY * 2; + var barText = + $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)} @ {playerSpeedText}"; + + var barTextSize = ImGui.CalcTextSize(barText); + + var barTextPos = new Vector2( + barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, + barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 + ); + + UiSharedService.DrawOutlinedFont( + drawList, + barText, + barTextPos, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + + cursor.Y += perPlayerBarHeight + spacingY; } } } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 255e99e..8b79c53 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -65,7 +65,6 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiShared; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; private readonly NameplateService _nameplateService; - private readonly NameplateHandler _nameplateHandler; private (int, int, FileCacheEntity) _currentProgress; private bool _deleteAccountPopupModalShown = false; private bool _deleteFilesPopupModalShown = false; @@ -170,7 +169,6 @@ public class SettingsUi : WindowMediatorSubscriberBase IpcManager ipcManager, CacheMonitor cacheMonitor, DalamudUtilService dalamudUtilService, HttpClient httpClient, NameplateService nameplateService, - NameplateHandler nameplateHandler, ActorObjectService actorObjectService) : base(logger, mediator, "Lightless Sync Settings", performanceCollector) { @@ -192,7 +190,6 @@ public class SettingsUi : WindowMediatorSubscriberBase _fileCompactor = fileCompactor; _uiShared = uiShared; _nameplateService = nameplateService; - _nameplateHandler = nameplateHandler; _actorObjectService = actorObjectService; AllowClickthrough = false; AllowPinning = true; @@ -887,11 +884,29 @@ public class SettingsUi : WindowMediatorSubscriberBase } bool showPlayerLinesTransferWindow = _configService.Current.ShowPlayerLinesTransferWindow; - if (ImGui.Checkbox("Toggle the Player Lines in the Transfer Window", ref showPlayerLinesTransferWindow)) + if (ImGui.Checkbox("Toggle the player lines in the Transfer Window", ref showPlayerLinesTransferWindow)) { _configService.Current.ShowPlayerLinesTransferWindow = showPlayerLinesTransferWindow; _configService.Save(); } + + if (!showPlayerLinesTransferWindow) + { + _configService.Current.ShowPlayerSpeedBarsTransferWindow = false; + ImGui.BeginDisabled(); + } + + bool showPlayerSpeedBarsTransferWindow = _configService.Current.ShowPlayerSpeedBarsTransferWindow; + if (ImGui.Checkbox("Toggle the download bars in player lines in the Transfer Window", ref showPlayerSpeedBarsTransferWindow)) + { + _configService.Current.ShowPlayerSpeedBarsTransferWindow = showPlayerSpeedBarsTransferWindow; + _configService.Save(); + } + + if (!showPlayerLinesTransferWindow) + { + ImGui.EndDisabled(); + } ImGui.Unindent(); if (!_configService.Current.ShowTransferWindow) ImGui.EndDisabled(); @@ -1928,8 +1943,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelOffsetX = (short)offsetX; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1937,8 +1950,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelOffsetX = 0; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1953,8 +1964,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelOffsetY = (short)offsetY; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1962,8 +1971,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelOffsetY = 0; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1975,8 +1982,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelScale = labelScale; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1984,8 +1989,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelScale = 1.0f; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -1999,8 +2002,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderAutoAlign = autoAlign; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -2032,7 +2033,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LabelAlignment = option; _configService.Save(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -2053,8 +2053,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelShowOwn = showOwn; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -2065,8 +2063,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelShowPaired = showPaired; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } @@ -2077,8 +2073,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelShowHidden = showHidden; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); } _uiShared.DrawHelpText("Toggles Lightfinder label when no nameplate(s) is visible."); @@ -2091,13 +2085,11 @@ public class SettingsUi : WindowMediatorSubscriberBase { _configService.Current.LightfinderLabelUseIcon = useIcon; _configService.Save(); - _nameplateHandler.ClearNameplateCaches(); - _nameplateHandler.FlagRefresh(); _nameplateService.RequestRedraw(); if (useIcon) { - RefreshLightfinderIconState(); + // redo } else { @@ -2110,78 +2102,7 @@ public class SettingsUi : WindowMediatorSubscriberBase if (useIcon) { - if (!_lightfinderIconInputInitialized) - { - RefreshLightfinderIconState(); - } - - var currentPresetLabel = _lightfinderIconPresetIndex >= 0 - ? $"{GetLightfinderPresetGlyph(_lightfinderIconPresetIndex)} {LightfinderIconPresets[_lightfinderIconPresetIndex].Label}" - : "Custom"; - - if (ImGui.BeginCombo("Preset Icon", currentPresetLabel)) - { - 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 = NameplateHandler.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 = NameplateHandler.NormalizeIconGlyph(_lightfinderIconInput); - ApplyLightfinderIcon(normalized, _lightfinderIconPresetIndex); - } - - ImGui.SameLine(); - if (ImGui.Button("Reset Icon")) - { - var defaultGlyph = NameplateHandler.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 = NameplateHandler.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."); + //redo } else { @@ -3852,40 +3773,6 @@ public class SettingsUi : WindowMediatorSubscriberBase return (true, failedConversions.Count != 0, sb.ToString()); } - private static string GetLightfinderPresetGlyph(int index) - { - return NameplateHandler.NormalizeIconGlyph( - SeIconCharExtensions.ToIconString(LightfinderIconPresets[index].Icon)); - } - - private void RefreshLightfinderIconState() - { - var normalized = NameplateHandler.NormalizeIconGlyph(_configService.Current.LightfinderLabelIconGlyph); - _lightfinderIconInput = NameplateHandler.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(); - _nameplateHandler.FlagRefresh(); - _nameplateService.RequestRedraw(); - _lightfinderIconInput = NameplateHandler.ToIconEditorString(normalizedGlyph); - _lightfinderIconPresetIndex = presetIndex; - _lightfinderIconInputInitialized = true; - } - private void DrawSettingsContent() { if (_apiController.ServerState is ServerState.Connected) -- 2.49.1 From feec5e8ff39899cd9745c7c60aeca1bff914fe55 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 5 Dec 2025 04:48:55 +0100 Subject: [PATCH 077/140] Pushed Imgui plate handler for lightfinder. need to redo options of it. --- LightlessSync/Plugin.cs | 10 +- .../LightFinder/LightFinderPlateHandler.cs | 249 +++++++ .../LightFinder/LightFinderScannerService.cs | 14 +- LightlessSync/Services/NameplateHandler.cs | 693 ------------------ LightlessSync/UI/EditProfileUi.Group.cs | 4 +- LightlessSync/UI/EditProfileUi.cs | 75 +- 6 files changed, 298 insertions(+), 747 deletions(-) create mode 100644 LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs delete mode 100644 LightlessSync/Services/NameplateHandler.cs diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 08065d1..b61ad55 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -294,7 +294,10 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService())); collection.AddSingleton(); collection.AddSingleton(s => new LightFinderScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - + collection.AddSingleton((s) => new LightFinderPlateHandler(s.GetRequiredService>(), + s.GetRequiredService(), pluginInterface, + s.GetRequiredService(), + objectTable, gameGui)); // add scoped services collection.AddScoped(); @@ -346,9 +349,7 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), clientState, gameGui, objectTable, gameInteropProvider, - s.GetRequiredService(),s.GetRequiredService())); - collection.AddScoped((s) => new NameplateHandler(s.GetRequiredService>(), addonLifecycle, gameGui, - s.GetRequiredService(), s.GetRequiredService(), objectTable, s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService())); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); @@ -365,6 +366,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); collection.AddHostedService(p => p.GetRequiredService()); + collection.AddHostedService(p => p.GetRequiredService()); }) .Build(); diff --git a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs new file mode 100644 index 0000000..8002beb --- /dev/null +++ b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs @@ -0,0 +1,249 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.ClientState.Objects.SubKinds; +using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Interface; +using Dalamud.Plugin; +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Object; +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services.Mediator; +using LightlessSync.UI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Collections.Immutable; +using System.Numerics; + +namespace LightlessSync.Services.LightFinder +{ + public class LightFinderPlateHandler : IHostedService, IMediatorSubscriber + { + private readonly ILogger _logger; + private readonly LightlessConfigService _configService; + private readonly IDalamudPluginInterface _pluginInterface; + private readonly IObjectTable _gameObjects; + private readonly IGameGui _gameGui; + + private const float _defaultNameplateDistance = 15.0f; + private ImmutableHashSet _activeBroadcastingCids = []; + private readonly Dictionary _smoothed = []; + private readonly float _defaultHeightOffset = 0f; + + public LightlessMediator Mediator { get; } + + public LightFinderPlateHandler( + ILogger logger, + LightlessMediator mediator, + IDalamudPluginInterface dalamudPluginInterface, + LightlessConfigService configService, + IObjectTable gameObjects, + IGameGui gameGui) + { + _logger = logger; + Mediator = mediator; + _pluginInterface = dalamudPluginInterface; + _configService = configService; + _gameObjects = gameObjects; + _gameGui = gameGui; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting LightFinderPlateHandler..."); + + _pluginInterface.UiBuilder.Draw += OnDraw; + + _logger.LogInformation("LightFinderPlateHandler started."); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Stopping LightFinderPlateHandler..."); + + _pluginInterface.UiBuilder.Draw -= OnDraw; + + _logger.LogInformation("LightFinderPlateHandler stopped."); + return Task.CompletedTask; + } + + private unsafe void OnDraw() + { + if (!_configService.Current.BroadcastEnabled) + return; + + if (_activeBroadcastingCids.Count == 0) + return; + + var drawList = ImGui.GetForegroundDrawList(); + + foreach (var obj in _gameObjects.PlayerObjects.OfType()) + { + //Double check to be sure, should always be true due to OfType filter above + if (obj is not IPlayerCharacter player) + continue; + + if (player.Address == IntPtr.Zero) + continue; + + var hashedCID = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address); + if (!_activeBroadcastingCids.Contains(hashedCID)) + continue; + + //Approximate check if nameplate should be visible (at short distances) + if (!ShouldApproximateNameplateVisible(player)) + continue; + + if (!TryGetApproxNameplateScreenPos(player, out var rawScreenPos)) + continue; + + var rawVector3 = new Vector3(rawScreenPos.X, rawScreenPos.Y, 0f); + + if (rawVector3 == Vector3.Zero) + { + _smoothed.Remove(obj); + continue; + } + + //Possible have to rework this. Currently just a simple distance check to avoid jitter. + Vector3 smoothedVector3; + + if (_smoothed.TryGetValue(obj, out var lastVector3)) + { + var deltaVector2 = new Vector2(rawVector3.X - lastVector3.X, rawVector3.Y - lastVector3.Y); + if (deltaVector2.Length() < 1f) + smoothedVector3 = lastVector3; + else + smoothedVector3 = rawVector3; + } + else + { + smoothedVector3 = rawVector3; + } + + _smoothed[obj] = smoothedVector3; + + var screenPos = new Vector2(smoothedVector3.X, smoothedVector3.Y); + + var radiusWorld = Math.Max(player.HitboxRadius, 0.5f); + var radiusPx = radiusWorld * 8.0f; + var offsetPx = GetScreenOffset(player); + var drawPos = new Vector2(screenPos.X, screenPos.Y - offsetPx); + + var fillColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("Lightfinder"))); + var outlineColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("LightfinderEdge"))); + + drawList.AddCircleFilled(drawPos, radiusPx, fillColor); + drawList.AddCircle(drawPos, radiusPx, outlineColor, 0, 2.0f); + + var label = "LightFinder"; + var icon = FontAwesomeIcon.Bullseye.ToIconString(); + + ImGui.PushFont(UiBuilder.IconFont); + var iconSize = ImGui.CalcTextSize(icon); + var iconPos = new Vector2(drawPos.X - iconSize.X / 2f, drawPos.Y - radiusPx - iconSize.Y - 2f); + drawList.AddText(iconPos, fillColor, icon); + ImGui.PopFont(); + + /* var scale = 1.4f; + var font = ImGui.GetFont(); + var baseFontSize = ImGui.GetFontSize(); + var fontSize = baseFontSize * scale; + + var baseTextSize = ImGui.CalcTextSize(label); + var textSize = baseTextSize * scale; + + var textPos = new Vector2( + drawPos.X - textSize.X / 2f, + drawPos.Y - radiusPx - textSize.Y - 2f + ); + + drawList.AddText(font, fontSize, textPos, fillColor, label); */ + } + } + + // Get screen offset based on distance to local player (to scale size appropriately) + // I need to fine tune these values still + private float GetScreenOffset(IPlayerCharacter player) + { + var local = _gameObjects.LocalPlayer; + if (local == null) + return 32.1f; + + var delta = player.Position - local.Position; + var dist = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); + + const float minDist = 2.1f; + const float maxDist = 30.4f; + dist = Math.Clamp(dist, minDist, maxDist); + + var t = 1f - (dist - minDist) / (maxDist - minDist); + + const float minOffset = 24.4f; + const float maxOffset = 56.4f; + return minOffset + (maxOffset - minOffset) * t; + } + + private bool TryGetApproxNameplateScreenPos(IPlayerCharacter player, out Vector2 screenPos) + { + screenPos = default; + + var worldPos = player.Position; + + var visualHeight = GetVisualHeight(player); + + worldPos.Y += (visualHeight + 1.2f) + _defaultHeightOffset; + + if (!_gameGui.WorldToScreen(worldPos, out var raw)) + return false; + + screenPos = raw; + return true; + } + + // Approximate check to see if nameplate would be visible based on distance and screen position + // Also has to be fine tuned still + private bool ShouldApproximateNameplateVisible(IPlayerCharacter player) + { + var local = _gameObjects.LocalPlayer; + if (local == null) + return false; + + var delta = player.Position - local.Position; + var distance2D = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); + if (distance2D > _defaultNameplateDistance) + return false; + + var verticalDelta = MathF.Abs(delta.Y); + if (verticalDelta > 3.4f) + return false; + + return TryGetApproxNameplateScreenPos(player, out _); + } + + private static unsafe float GetVisualHeight(IPlayerCharacter player) + { + var gameObject = (GameObject*)player.Address; + if (gameObject == null) + return Math.Max(player.HitboxRadius * 2.0f, 1.7f); // fallback + + // This should account for transformations (sitting, crouching, etc.) + var radius = gameObject->GetRadius(adjustByTransformation: true); + if (radius <= 0) + radius = Math.Max(player.HitboxRadius * 2.0f, 1.7f); + + return radius; + } + + // Update the set of active broadcasting CIDs (Same uses as in NameplateHnadler before) + public void UpdateBroadcastingCids(IEnumerable cids) + { + var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); + if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) + return; + + _activeBroadcastingCids = newSet; + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); + } + } +} \ No newline at end of file diff --git a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs index a0ea3e6..52ff1dc 100644 --- a/LightlessSync/Services/LightFinder/LightFinderScannerService.cs +++ b/LightlessSync/Services/LightFinder/LightFinderScannerService.cs @@ -14,7 +14,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase private readonly IFramework _framework; private readonly LightFinderService _broadcastService; - private readonly NameplateHandler _nameplateHandler; + private readonly LightFinderPlateHandler _lightFinderPlateHandler; private readonly ConcurrentDictionary _broadcastCache = new(StringComparer.Ordinal); private readonly Queue _lookupQueue = new(); @@ -41,22 +41,21 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase IFramework framework, LightFinderService broadcastService, LightlessMediator mediator, - NameplateHandler nameplateHandler, + LightFinderPlateHandler lightFinderPlateHandler, ActorObjectService actorTracker) : base(logger, mediator) { _logger = logger; _actorTracker = actorTracker; _broadcastService = broadcastService; - _nameplateHandler = nameplateHandler; + _lightFinderPlateHandler = lightFinderPlateHandler; _logger = logger; _framework = framework; _framework.Update += OnFrameworkUpdate; Mediator.Subscribe(this, OnBroadcastStatusChanged); - _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop); + _cleanupTask = Task.Run(ExpiredBroadcastCleanupLoop, _cleanupCts.Token); - _nameplateHandler.Init(); _actorTracker = actorTracker; } @@ -129,7 +128,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase .Select(e => e.Key) .ToList(); - _nameplateHandler.UpdateBroadcastingCids(activeCids); + _lightFinderPlateHandler.UpdateBroadcastingCids(activeCids); UpdateSyncshellBroadcasts(); } @@ -142,7 +141,7 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _lookupQueuedCids.Clear(); _syncshellCids.Clear(); - _nameplateHandler.UpdateBroadcastingCids(Enumerable.Empty()); + _lightFinderPlateHandler.UpdateBroadcastingCids([]); } } @@ -243,6 +242,5 @@ public class LightFinderScannerService : DisposableMediatorSubscriberBase _cleanupTask?.Wait(100); _cleanupCts.Dispose(); - _nameplateHandler.Uninit(); } } diff --git a/LightlessSync/Services/NameplateHandler.cs b/LightlessSync/Services/NameplateHandler.cs deleted file mode 100644 index 808242d..0000000 --- a/LightlessSync/Services/NameplateHandler.cs +++ /dev/null @@ -1,693 +0,0 @@ -using Dalamud.Game.Addon.Lifecycle; -using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; -using Dalamud.Game.ClientState.Objects.Enums; -using Dalamud.Game.Text; -using Dalamud.Plugin.Services; -using FFXIVClientStructs.FFXIV.Client.System.Framework; -using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Component.GUI; -using LightlessSync.LightlessConfiguration; -using LightlessSync.Services.Mediator; -using LightlessSync.UI; -using LightlessSync.UI.Services; -using LightlessSync.Utils; -using LightlessSync.UtilsEnum.Enum; - -// Created using https://github.com/PunishedPineapple/Distance as a reference, thank you! - -using Microsoft.Extensions.Logging; -using System.Collections.Immutable; -using System.Globalization; - -namespace LightlessSync.Services; - -public unsafe class NameplateHandler : IMediatorSubscriber -{ - private readonly ILogger _logger; - private readonly IAddonLifecycle _addonLifecycle; - private readonly IGameGui _gameGui; - private readonly IObjectTable _objectTable; - private readonly LightlessConfigService _configService; - private readonly PairUiService _pairUiService; - private readonly LightlessMediator _mediator; - public LightlessMediator Mediator => _mediator; - - private bool _mEnabled = false; - private bool _needsLabelRefresh = false; - private AddonNamePlate* _mpNameplateAddon = null; - private readonly AtkTextNode*[] _mTextNodes = new AtkTextNode*[AddonNamePlate.NumNamePlateObjects]; - private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects]; - private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects]; - private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects]; - private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects]; - - internal const uint mNameplateNodeIDBase = 0x7D99D500; - private const string DefaultLabelText = "LightFinder"; - private const SeIconChar DefaultIcon = SeIconChar.Hyadelyn; - private static readonly string DefaultIconGlyph = SeIconCharExtensions.ToIconString(DefaultIcon); - - private ImmutableHashSet _activeBroadcastingCids = []; - - public NameplateHandler(ILogger logger, IAddonLifecycle addonLifecycle, IGameGui gameGui, LightlessConfigService configService, LightlessMediator mediator, IObjectTable objectTable, PairUiService pairUiService) - { - _logger = logger; - _addonLifecycle = addonLifecycle; - _gameGui = gameGui; - _configService = configService; - _mediator = mediator; - _objectTable = objectTable; - _pairUiService = pairUiService; - - System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); - } - - internal void Init() - { - EnableNameplate(); - _mediator.Subscribe(this, OnTick); - } - - internal void Uninit() - { - DisableNameplate(); - DestroyNameplateNodes(); - _mediator.Unsubscribe(this); - _mpNameplateAddon = null; - } - - internal void EnableNameplate() - { - if (!_mEnabled) - { - try - { - _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); - _mEnabled = true; - } - catch (Exception e) - { - _logger.LogError($"Unknown error while trying to enable nameplate distances:\n{e}"); - DisableNameplate(); - } - } - } - - internal void DisableNameplate() - { - if (_mEnabled) - { - try - { - _addonLifecycle.UnregisterListener(NameplateDrawDetour); - } - catch (Exception e) - { - _logger.LogError($"Unknown error while unregistering nameplate listener:\n{e}"); - } - - _mEnabled = false; - HideAllNameplateNodes(); - } - } - - private void NameplateDrawDetour(AddonEvent type, AddonArgs args) - { - if (args.Addon.Address == nint.Zero) - { - if (_logger.IsEnabled(LogLevel.Warning)) - _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); - return; - } - - var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; - - if (_mpNameplateAddon != pNameplateAddon) - { - for (int i = 0; i < _mTextNodes.Length; ++i) _mTextNodes[i] = null; - System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); - System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); - System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); - System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); - _mpNameplateAddon = pNameplateAddon; - if (_mpNameplateAddon != null) CreateNameplateNodes(); - } - - UpdateNameplateNodes(); - } - - private void CreateNameplateNodes() - { - for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) - { - var nameplateObject = GetNameplateObject(i); - if (nameplateObject == null) - continue; - - var rootNode = nameplateObject.Value.RootComponentNode; - if (rootNode == null || rootNode->Component == null) - continue; - - var pNameplateResNode = nameplateObject.Value.NameContainer; - if (pNameplateResNode == null) - continue; - if (pNameplateResNode->ChildNode == null) - continue; - - var pNewNode = AtkNodeHelpers.CreateOrphanTextNode(mNameplateNodeIDBase + (uint)i, TextFlags.Edge | TextFlags.Glare); - - if (pNewNode != null) - { - var pLastChild = pNameplateResNode->ChildNode; - while (pLastChild->PrevSiblingNode != null) pLastChild = pLastChild->PrevSiblingNode; - pNewNode->AtkResNode.NextSiblingNode = pLastChild; - pNewNode->AtkResNode.ParentNode = pNameplateResNode; - pLastChild->PrevSiblingNode = (AtkResNode*)pNewNode; - rootNode->Component->UldManager.UpdateDrawNodeList(); - pNewNode->AtkResNode.SetUseDepthBasedPriority(true); - _mTextNodes[i] = pNewNode; - } - } - } - - private void DestroyNameplateNodes() - { - var currentHandle = _gameGui.GetAddonByName("NamePlate", 1); - if (currentHandle.Address == nint.Zero) - { - if (_logger.IsEnabled(LogLevel.Warning)) - _logger.LogWarning("Unable to destroy nameplate nodes because the NamePlate addon is not available."); - return; - } - - var pCurrentNameplateAddon = (AddonNamePlate*)currentHandle.Address; - if (_mpNameplateAddon == null) - return; - - if (_mpNameplateAddon != pCurrentNameplateAddon) - { - if (_logger.IsEnabled(LogLevel.Warning)) - _logger.LogWarning("Skipping nameplate node destroy due to addon address mismatch (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)pCurrentNameplateAddon); - return; - } - - for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i) - { - var pTextNode = _mTextNodes[i]; - var pNameplateNode = GetNameplateComponentNode(i); - if (pTextNode != null && (pNameplateNode == null || pNameplateNode->Component == null)) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("Skipping destroy for nameplate {Index} because its component node is unavailable.", i); - continue; - } - - if (pTextNode != null && pNameplateNode != null && pNameplateNode->Component != null) - { - try - { - if (pTextNode->AtkResNode.PrevSiblingNode != null) - pTextNode->AtkResNode.PrevSiblingNode->NextSiblingNode = pTextNode->AtkResNode.NextSiblingNode; - if (pTextNode->AtkResNode.NextSiblingNode != null) - pTextNode->AtkResNode.NextSiblingNode->PrevSiblingNode = pTextNode->AtkResNode.PrevSiblingNode; - pNameplateNode->Component->UldManager.UpdateDrawNodeList(); - pTextNode->AtkResNode.Destroy(free: true); - _mTextNodes[i] = null; - } - catch (Exception e) - { - if (_logger.IsEnabled(LogLevel.Error)) - _logger.LogError("Unknown error while removing text node 0x{textNode} for nameplate {i} on component node 0x{nameplateNode}:\n{e}", (IntPtr)pTextNode, i, (IntPtr)pNameplateNode, e); - } - } - } - - System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); - System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); - System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); - System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); - } - - private void HideAllNameplateNodes() - { - for (int i = 0; i < _mTextNodes.Length; ++i) - { - HideNameplateTextNode(i); - } - } - - private void UpdateNameplateNodes() - { - var currentHandle = _gameGui.GetAddonByName("NamePlate"); - if (currentHandle.Address == nint.Zero) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); - return; - } - - var currentAddon = (AddonNamePlate*)currentHandle.Address; - if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) - { - if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); - return; - } - - var framework = Framework.Instance(); - if (framework == null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); - return; - } - - var uiModule = framework->GetUIModule(); - if (uiModule == null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("UI module unavailable during nameplate update, skipping."); - return; - } - - var ui3DModule = uiModule->GetUI3DModule(); - if (ui3DModule == null) - { - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); - return; - } - - var vec = ui3DModule->NamePlateObjectInfoPointers; - if (vec.IsEmpty) - return; - - var visibleUserIdsSnapshot = VisibleUserIds; - - var safeCount = System.Math.Min( - ui3DModule->NamePlateObjectInfoCount, - vec.Length - ); - - for (int i = 0; i < safeCount; ++i) - { - var config = _configService.Current; - - var objectInfoPtr = vec[i]; - if (objectInfoPtr == null) - continue; - - var objectInfo = objectInfoPtr.Value; - if (objectInfo == null || objectInfo->GameObject == null) - continue; - - var nameplateIndex = objectInfo->NamePlateIndex; - if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) - continue; - - var pNode = _mTextNodes[nameplateIndex]; - if (pNode == null) - continue; - - var gameObject = objectInfo->GameObject; - if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player) - { - pNode->AtkResNode.ToggleVisibility(enable: false); - continue; - } - - // CID gating - var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); - if (cid == null || !_activeBroadcastingCids.Contains(cid)) - { - pNode->AtkResNode.ToggleVisibility(enable: false); - continue; - } - - var local = _objectTable.LocalPlayer; - if (!config.LightfinderLabelShowOwn && local != null && - objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) - { - pNode->AtkResNode.ToggleVisibility(enable: false); - continue; - } - - var hidePaired = !config.LightfinderLabelShowPaired; - - var goId = (ulong)gameObject->GetGameObjectId(); - if (hidePaired && visibleUserIdsSnapshot.Contains(goId)) - { - pNode->AtkResNode.ToggleVisibility(enable: false); - continue; - } - - var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; - 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) - { - _logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex); - pNode->AtkResNode.ToggleVisibility(enable: false); - continue; - } - - root->Component->UldManager.UpdateDrawNodeList(); - - bool isVisible = - ((marker != null) && marker->AtkResNode.IsVisible()) || - (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || - config.LightfinderLabelShowHidden; - - pNode->AtkResNode.ToggleVisibility(isVisible); - if (!isVisible) - continue; - - var labelColor = UIColors.Get("Lightfinder"); - var edgeColor = UIColors.Get("LightfinderEdge"); - - var scaleMultiplier = System.Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f); - var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f; - var effectiveScale = baseScale * scaleMultiplier; - var labelContent = config.LightfinderLabelUseIcon - ? NormalizeIconGlyph(config.LightfinderLabelIconGlyph) - : DefaultLabelText; - - pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; - pNode->AtkResNode.SetScale(effectiveScale, effectiveScale); - var nodeWidth = (int)pNode->AtkResNode.GetWidth(); - if (nodeWidth <= 0) - nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); - var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); - var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f; - var computedFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); - pNode->FontSize = (byte)System.Math.Clamp(computedFontSize, 1, 255); - AlignmentType alignment; - - var textScaleY = nameText->AtkResNode.ScaleY; - if (textScaleY <= 0f) - textScaleY = 1f; - - var blockHeight = System.Math.Abs((int)nameplateObject.TextH); - if (blockHeight > 0) - { - _cachedNameplateTextHeights[nameplateIndex] = blockHeight; - } - else - { - blockHeight = _cachedNameplateTextHeights[nameplateIndex]; - } - - if (blockHeight <= 0) - { - blockHeight = GetScaledTextHeight(nameText); - if (blockHeight <= 0) - blockHeight = nodeHeight; - - _cachedNameplateTextHeights[nameplateIndex] = blockHeight; - } - - var containerHeight = (int)nameContainer->Height; - if (containerHeight > 0) - { - _cachedNameplateContainerHeights[nameplateIndex] = containerHeight; - } - else - { - containerHeight = _cachedNameplateContainerHeights[nameplateIndex]; - } - - if (containerHeight <= 0) - { - containerHeight = blockHeight + (int)System.Math.Round(8 * textScaleY); - if (containerHeight <= blockHeight) - containerHeight = blockHeight + 1; - - _cachedNameplateContainerHeights[nameplateIndex] = containerHeight; - } - - var blockTop = containerHeight - blockHeight; - if (blockTop < 0) - blockTop = 0; - var verticalPadding = (int)System.Math.Round(4 * effectiveScale); - - var positionY = blockTop - verticalPadding - nodeHeight; - - var textWidth = System.Math.Abs((int)nameplateObject.TextW); - if (textWidth <= 0) - { - textWidth = GetScaledTextWidth(nameText); - if (textWidth <= 0) - textWidth = nodeWidth; - } - - if (textWidth > 0) - { - _cachedNameplateTextWidths[nameplateIndex] = textWidth; - } - - var textOffset = (int)System.Math.Round(nameText->AtkResNode.X); - var hasValidOffset = true; - - if (System.Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0) - { - _cachedNameplateTextOffsets[nameplateIndex] = textOffset; - } - else - { - hasValidOffset = false; - } - int positionX; - - - if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) - labelContent = DefaultLabelText; - - pNode->FontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed; - - pNode->SetText(labelContent); - - if (!config.LightfinderLabelUseIcon) - { - pNode->TextFlags &= ~TextFlags.AutoAdjustNodeSize; - pNode->AtkResNode.Width = 0; - nodeWidth = (int)pNode->AtkResNode.GetWidth(); - if (nodeWidth <= 0) - nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); - pNode->AtkResNode.Width = (ushort)nodeWidth; - } - else - { - pNode->TextFlags |= TextFlags.AutoAdjustNodeSize; - pNode->AtkResNode.Width = 0; - nodeWidth = pNode->AtkResNode.GetWidth(); - } - - - if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset) - { - var nameplateWidth = (int)nameContainer->Width; - - int leftPos = nameplateWidth / 8; - int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8); - int centrePos = (nameplateWidth - nodeWidth) / 2; - int staticMargin = 24; - int calcMargin = (int)(nameplateWidth * 0.08f); - - switch (config.LabelAlignment) - { - case LabelAlignment.Left: - positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos; - alignment = AlignmentType.BottomLeft; - break; - case LabelAlignment.Right: - positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin; - alignment = AlignmentType.BottomRight; - break; - default: - positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin; - alignment = AlignmentType.Bottom; - break; - } - } - else - { - positionX = 58 + config.LightfinderLabelOffsetX; - alignment = AlignmentType.Bottom; - } - - positionY += config.LightfinderLabelOffsetY; - - alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); - pNode->AtkResNode.SetUseDepthBasedPriority(enable: true); - - pNode->AtkResNode.Color.A = 255; - - pNode->TextColor.R = (byte)(labelColor.X * 255); - pNode->TextColor.G = (byte)(labelColor.Y * 255); - pNode->TextColor.B = (byte)(labelColor.Z * 255); - pNode->TextColor.A = (byte)(labelColor.W * 255); - - pNode->EdgeColor.R = (byte)(edgeColor.X * 255); - pNode->EdgeColor.G = (byte)(edgeColor.Y * 255); - pNode->EdgeColor.B = (byte)(edgeColor.Z * 255); - pNode->EdgeColor.A = (byte)(edgeColor.W * 255); - - - if (!config.LightfinderLabelUseIcon) - { - pNode->AlignmentType = AlignmentType.Bottom; - } - else - { - pNode->AlignmentType = alignment; - } - pNode->AtkResNode.SetPositionShort( - (short)System.Math.Clamp(positionX, short.MinValue, short.MaxValue), - (short)System.Math.Clamp(positionY, short.MinValue, short.MaxValue) - ); - var computedLineSpacing = (int)System.Math.Round(24 * scaleMultiplier); - pNode->LineSpacing = (byte)System.Math.Clamp(computedLineSpacing, 0, byte.MaxValue); - pNode->CharSpacing = 1; - pNode->TextFlags = config.LightfinderLabelUseIcon - ? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize - : TextFlags.Edge | TextFlags.Glare; - } - } - - private static unsafe int GetScaledTextHeight(AtkTextNode* node) - { - if (node == null) - return 0; - - var resNode = &node->AtkResNode; - var rawHeight = (int)resNode->GetHeight(); - if (rawHeight <= 0 && node->LineSpacing > 0) - rawHeight = node->LineSpacing; - if (rawHeight <= 0) - rawHeight = AtkNodeHelpers.DefaultTextNodeHeight; - - var scale = resNode->ScaleY; - if (scale <= 0f) - scale = 1f; - - var computed = (int)System.Math.Round(rawHeight * scale); - return System.Math.Max(1, computed); - } - - private static unsafe int GetScaledTextWidth(AtkTextNode* node) - { - if (node == null) - return 0; - - var resNode = &node->AtkResNode; - var rawWidth = (int)resNode->GetWidth(); - if (rawWidth <= 0) - rawWidth = AtkNodeHelpers.DefaultTextNodeWidth; - - var scale = resNode->ScaleX; - if (scale <= 0f) - scale = 1f; - - var computed = (int)System.Math.Round(rawWidth * scale); - return System.Math.Max(1, computed); - } - - internal static string NormalizeIconGlyph(string? rawInput) - { - if (string.IsNullOrWhiteSpace(rawInput)) - return DefaultIconGlyph; - - var trimmed = rawInput.Trim(); - - if (Enum.TryParse(trimmed, true, out var iconEnum)) - return SeIconCharExtensions.ToIconString(iconEnum); - - var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? trimmed[2..] - : trimmed; - - if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue)) - return char.ConvertFromUtf32(hexValue); - - var enumerator = trimmed.EnumerateRunes(); - if (enumerator.MoveNext()) - return enumerator.Current.ToString(); - - return DefaultIconGlyph; - } - - 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; - } - private void HideNameplateTextNode(int i) - { - var pNode = _mTextNodes[i]; - if (pNode != null) - { - pNode->AtkResNode.ToggleVisibility(false); - } - } - - private AddonNamePlate.NamePlateObject? GetNameplateObject(int i) - { - if (i < AddonNamePlate.NumNamePlateObjects && - _mpNameplateAddon != null && - _mpNameplateAddon->NamePlateObjectArray[i].RootComponentNode != null) - { - return _mpNameplateAddon->NamePlateObjectArray[i]; - } - return null; - } - - private AtkComponentNode* GetNameplateComponentNode(int i) - { - var nameplateObject = GetNameplateObject(i); - return nameplateObject != null ? nameplateObject.Value.RootComponentNode : null; - } - - private HashSet VisibleUserIds - => [.. _pairUiService.GetSnapshot().PairsByUid.Values - .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) - .Select(u => (ulong)u.PlayerCharacterId)]; - - public void FlagRefresh() - { - _needsLabelRefresh = true; - } - - public void OnTick(PriorityFrameworkUpdateMessage _) - { - if (_needsLabelRefresh) - { - UpdateNameplateNodes(); - _needsLabelRefresh = false; - } - } - - public void UpdateBroadcastingCids(IEnumerable cids) - { - var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); - if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) - return; - - _activeBroadcastingCids = newSet; - if (_logger.IsEnabled(LogLevel.Information)) - _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); - FlagRefresh(); - } - - public void ClearNameplateCaches() - { - System.Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length); - System.Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length); - System.Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length); - System.Array.Fill(_cachedNameplateTextOffsets, int.MinValue); - } -} diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index cae1eeb..6e8f6d3 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -277,7 +277,7 @@ public partial class EditProfileUi if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) { - _fileDialogManager.OpenFileDialog("Select syncshell profile picture", ImageFileDialogFilter, (success, file) => + _fileDialogManager.OpenFileDialog("Select syncshell profile picture", _imageFileDialogFilter, (success, file) => { if (!success || string.IsNullOrEmpty(file)) return; @@ -305,7 +305,7 @@ public partial class EditProfileUi if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) { - _fileDialogManager.OpenFileDialog("Select syncshell profile banner", ImageFileDialogFilter, (success, file) => + _fileDialogManager.OpenFileDialog("Select syncshell profile banner", _imageFileDialogFilter, (success, file) => { if (!success || string.IsNullOrEmpty(file)) return; diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 3c9b8ae..78c38ed 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -19,12 +19,7 @@ using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; -using System; -using System.Collections.Generic; -using System.IO; using System.Numerics; -using System.Threading.Tasks; -using System.Linq; using LightlessSync.Services.Profiles; namespace LightlessSync.UI; @@ -56,9 +51,9 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase "webp", "bmp" }; - private const string ImageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; + private const string _imageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; private readonly List _tagEditorSelection = new(); - private int[] _profileTagIds = Array.Empty(); + private int[] _profileTagIds = []; private readonly List _tagPreviewSegments = new(); private enum ProfileEditorMode { @@ -77,8 +72,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private byte[]? _queuedBannerImage; private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); private readonly Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); - private const int MaxProfileTags = 12; - private const int AvailableTagsPerPage = 6; + private const int _maxProfileTags = 12; + private const int _availableTagsPerPage = 6; private int _availableTagPage; private UserData? _selfProfileUserData; private string _descriptionText = string.Empty; @@ -92,10 +87,10 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private bool _wasOpen; private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); - private bool textEnabled; - private bool glowEnabled; - private Vector4 textColor; - private Vector4 glowColor; + private bool _textEnabled; + private bool _glowEnabled; + private Vector4 _textColor; + private Vector4 _glowColor; private sealed record VanityState(bool TextEnabled, bool GlowEnabled, Vector4 TextColor, Vector4 GlowColor); private VanityState? _savedVanity; @@ -154,13 +149,13 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private void LoadVanity() { - textEnabled = !string.IsNullOrEmpty(_apiController.TextColorHex); - glowEnabled = !string.IsNullOrEmpty(_apiController.TextGlowColorHex); + _textEnabled = !string.IsNullOrEmpty(_apiController.TextColorHex); + _glowEnabled = !string.IsNullOrEmpty(_apiController.TextGlowColorHex); - textColor = textEnabled ? UIColors.HexToRgba(_apiController.TextColorHex!) : Vector4.One; - glowColor = glowEnabled ? UIColors.HexToRgba(_apiController.TextGlowColorHex!) : Vector4.Zero; + _textColor = _textEnabled ? UIColors.HexToRgba(_apiController.TextColorHex!) : Vector4.One; + _glowColor = _glowEnabled ? UIColors.HexToRgba(_apiController.TextGlowColorHex!) : Vector4.Zero; - _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); + _savedVanity = new VanityState(_textEnabled, _glowEnabled, _textColor, _glowColor); } public override async void OnOpen() @@ -465,7 +460,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile picture")) { var existingBanner = GetCurrentProfileBannerBase64(profile); - _fileDialogManager.OpenFileDialog("Select new Profile picture", ImageFileDialogFilter, (success, file) => + _fileDialogManager.OpenFileDialog("Select new Profile picture", _imageFileDialogFilter, (success, file) => { if (!success) return; _ = Task.Run(async () => @@ -529,7 +524,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase if (_uiSharedService.IconTextButton(FontAwesomeIcon.FileUpload, "Upload new profile banner")) { var existingProfile = GetCurrentProfilePictureBase64(profile); - _fileDialogManager.OpenFileDialog("Select new Profile banner", ImageFileDialogFilter, (success, file) => + _fileDialogManager.OpenFileDialog("Select new Profile banner", _imageFileDialogFilter, (success, file) => { if (!success) return; _ = Task.Run(async () => @@ -686,7 +681,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); var selectedCount = _tagEditorSelection.Count; - ImGui.TextColored(UIColors.Get("LightlessBlue"), $"Selected Tags ({selectedCount}/{MaxProfileTags})"); + ImGui.TextColored(UIColors.Get("LightlessBlue"), $"Selected Tags ({selectedCount}/{_maxProfileTags})"); int? tagToRemove = null; int? moveUpRequest = null; @@ -766,9 +761,9 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase if (tagToRemove.HasValue) _tagEditorSelection.Remove(tagToRemove.Value); - bool limitReached = _tagEditorSelection.Count >= MaxProfileTags; + bool limitReached = _tagEditorSelection.Count >= _maxProfileTags; if (limitReached) - UiSharedService.ColorTextWrapped($"You have reached the maximum of {MaxProfileTags} tags. Remove one before adding more.", UIColors.Get("DimRed")); + UiSharedService.ColorTextWrapped($"You have reached the maximum of {_maxProfileTags} tags. Remove one before adding more.", UIColors.Get("DimRed")); ImGui.Dummy(new Vector2(0f, 6f * scale)); ImGui.TextColored(UIColors.Get("LightlessPurple"), "Available Tags"); @@ -798,10 +793,10 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } else { - int pageCount = Math.Max(1, (totalAvailable + AvailableTagsPerPage - 1) / AvailableTagsPerPage); + int pageCount = Math.Max(1, (totalAvailable + _availableTagsPerPage - 1) / _availableTagsPerPage); _availableTagPage = Math.Clamp(_availableTagPage, 0, pageCount - 1); - int start = _availableTagPage * AvailableTagsPerPage; - int end = Math.Min(totalAvailable, start + AvailableTagsPerPage); + int start = _availableTagPage * _availableTagsPerPage; + int end = Math.Min(totalAvailable, start + _availableTagsPerPage); ImGui.SameLine(); ImGui.TextDisabled($"Page {_availableTagPage + 1}/{pageCount}"); @@ -1118,8 +1113,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase var monoFont = UiBuilder.MonoFont; using (ImRaii.PushFont(monoFont)) { - var previewTextColor = textEnabled ? textColor : Vector4.One; - var previewGlowColor = glowEnabled ? glowColor : Vector4.Zero; + var previewTextColor = _textEnabled ? _textColor : Vector4.One; + var previewGlowColor = _glowEnabled ? _glowColor : Vector4.Zero; var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.DisplayName, previewTextColor, previewGlowColor); var drawList = ImGui.GetWindowDrawList(); @@ -1151,33 +1146,33 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase if (!hasVanity) ImGui.BeginDisabled(); - if (DrawCheckboxRow("Enable custom text color", textEnabled, out var newTextEnabled)) - textEnabled = newTextEnabled; + if (DrawCheckboxRow("Enable custom text color", _textEnabled, out var newTextEnabled)) + _textEnabled = newTextEnabled; ImGui.SameLine(); - ImGui.BeginDisabled(!textEnabled); - ImGui.ColorEdit4("Text Color##vanityTextColor", ref textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); + ImGui.BeginDisabled(!_textEnabled); + ImGui.ColorEdit4("Text Color##vanityTextColor", ref _textColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); ImGui.EndDisabled(); - if (DrawCheckboxRow("Enable glow color", glowEnabled, out var newGlowEnabled)) - glowEnabled = newGlowEnabled; + if (DrawCheckboxRow("Enable glow color", _glowEnabled, out var newGlowEnabled)) + _glowEnabled = newGlowEnabled; ImGui.SameLine(); - ImGui.BeginDisabled(!glowEnabled); - ImGui.ColorEdit4("Glow Color##vanityGlowColor", ref glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); + ImGui.BeginDisabled(!_glowEnabled); + ImGui.ColorEdit4("Glow Color##vanityGlowColor", ref _glowColor, ImGuiColorEditFlags.NoInputs | ImGuiColorEditFlags.AlphaPreviewHalf); ImGui.EndDisabled(); - bool changed = !Equals(_savedVanity, new VanityState(textEnabled, glowEnabled, textColor, glowColor)); + bool changed = !Equals(_savedVanity, new VanityState(_textEnabled, _glowEnabled, _textColor, _glowColor)); if (!changed) ImGui.BeginDisabled(); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Save Vanity Changes")) { - string? newText = textEnabled ? UIColors.RgbaToHex(textColor) : string.Empty; - string? newGlow = glowEnabled ? UIColors.RgbaToHex(glowColor) : string.Empty; + string? newText = _textEnabled ? UIColors.RgbaToHex(_textColor) : string.Empty; + string? newGlow = _glowEnabled ? UIColors.RgbaToHex(_glowColor) : string.Empty; _ = _apiController.UserUpdateVanityColors(new UserVanityColorsDto(newText, newGlow)); - _savedVanity = new VanityState(textEnabled, glowEnabled, textColor, glowColor); + _savedVanity = new VanityState(_textEnabled, _glowEnabled, _textColor, _glowColor); } if (!changed) -- 2.49.1 From b444782b760747b6b31b23292821ec52fd127bca Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 5 Dec 2025 13:36:12 +0100 Subject: [PATCH 078/140] Fixed plugin, added 0 zero (15 minutes) option for pruning. --- LightlessSync/Plugin.cs | 2 +- LightlessSync/UI/SyncshellAdminUI.cs | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index b61ad55..8f6031c 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -293,7 +293,7 @@ public sealed class Plugin : IDalamudPlugin clientState, sp.GetRequiredService())); collection.AddSingleton(); - collection.AddSingleton(s => new LightFinderScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); + collection.AddSingleton(s => new LightFinderScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton((s) => new LightFinderPlateHandler(s.GetRequiredService>(), s.GetRequiredService(), pluginInterface, s.GetRequiredService(), diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 0eef2e5..45b97a6 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -324,17 +324,20 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase + UiSharedService.TooltipSeparator + "Note: this check excludes pinned users and moderators of this Syncshell."); ImGui.SameLine(); ImGui.SetNextItemWidth(150); - _uiSharedService.DrawCombo("Day(s) of inactivity", [1, 3, 7, 14, 30, 90], (count) => - { - return count + " day(s)"; - }, - (selected) => - { - _pruneDays = selected; - _pruneTestTask = null; - _pruneTask = null; - }, - _pruneDays); + _uiSharedService.DrawCombo( + "Day(s) of inactivity", + [0, 1, 3, 7, 14, 30, 90], + (count) => + { + return count == 0 ? "15 minute(s)" : count + " day(s)"; + }, + (selected) => + { + _pruneDays = selected; + _pruneTestTask = null; + _pruneTask = null; + }, + _pruneDays); if (_pruneTestTask != null) { -- 2.49.1 From 1cb326070b8d53e4a53f682a112639bcd74aee37 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 6 Dec 2025 05:35:27 +0100 Subject: [PATCH 079/140] Added option to show green eye in pair list. --- .../Configurations/LightlessConfig.cs | 1 + .../Models/Obsolete/ServerStorageV0.cs | 29 ------------------- LightlessSync/UI/Components/DrawUserPair.cs | 22 ++++++++++---- LightlessSync/UI/DrawEntityFactory.cs | 1 + LightlessSync/UI/SettingsUi.cs | 15 +++++++--- 5 files changed, 30 insertions(+), 38 deletions(-) delete mode 100644 LightlessSync/LightlessConfiguration/Models/Obsolete/ServerStorageV0.cs diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 0b448ad..c16b380 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -63,6 +63,7 @@ public class LightlessConfig : ILightlessConfiguration public bool ShowOnlineNotifications { get; set; } = false; public bool ShowOnlineNotificationsOnlyForIndividualPairs { get; set; } = true; public bool ShowOnlineNotificationsOnlyForNamedPairs { get; set; } = false; + public bool ShowVisiblePairsGreenEye { get; set; } = false; public bool ShowTransferBars { get; set; } = true; public bool ShowTransferWindow { get; set; } = false; public bool ShowPlayerLinesTransferWindow { get; set; } = true; diff --git a/LightlessSync/LightlessConfiguration/Models/Obsolete/ServerStorageV0.cs b/LightlessSync/LightlessConfiguration/Models/Obsolete/ServerStorageV0.cs deleted file mode 100644 index 0cb5f3e..0000000 --- a/LightlessSync/LightlessConfiguration/Models/Obsolete/ServerStorageV0.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace LightlessSync.LightlessConfiguration.Models.Obsolete; - -[Serializable] -[Obsolete("Deprecated, use ServerStorage")] -public class ServerStorageV0 -{ - public List Authentications { get; set; } = []; - public bool FullPause { get; set; } = false; - public Dictionary GidServerComments { get; set; } = new(StringComparer.Ordinal); - public HashSet OpenPairTags { get; set; } = new(StringComparer.Ordinal); - public Dictionary SecretKeys { get; set; } = []; - public HashSet ServerAvailablePairTags { get; set; } = new(StringComparer.Ordinal); - public string ServerName { get; set; } = string.Empty; - public string ServerUri { get; set; } = string.Empty; - public Dictionary UidServerComments { get; set; } = new(StringComparer.Ordinal); - public Dictionary> UidServerPairedUserTags { get; set; } = new(StringComparer.Ordinal); - - public ServerStorage ToV1() - { - return new ServerStorage() - { - ServerUri = ServerUri, - ServerName = ServerName, - Authentications = [.. Authentications], - FullPause = FullPause, - SecretKeys = SecretKeys.ToDictionary(p => p.Key, p => p.Value) - }; - } -} \ No newline at end of file diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 877fe6d..96b9ea4 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -16,12 +16,8 @@ using LightlessSync.UI.Models; using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Text; -using LightlessSync.UI; namespace LightlessSync.UI.Components; @@ -40,6 +36,7 @@ public class DrawUserPair private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiSharedService; private readonly PlayerPerformanceConfigService _performanceConfigService; + private readonly LightlessConfigService _configService; private readonly CharaDataManager _charaDataManager; private readonly PairLedger _pairLedger; private float _menuWidth = -1; @@ -59,6 +56,7 @@ public class DrawUserPair ServerConfigurationManager serverConfigurationManager, UiSharedService uiSharedService, PlayerPerformanceConfigService performanceConfigService, + LightlessConfigService configService, CharaDataManager charaDataManager, PairLedger pairLedger) { @@ -75,6 +73,7 @@ public class DrawUserPair _serverConfigurationManager = serverConfigurationManager; _uiSharedService = uiSharedService; _performanceConfigService = performanceConfigService; + _configService = configService; _charaDataManager = charaDataManager; _pairLedger = pairLedger; } @@ -230,6 +229,11 @@ public class DrawUserPair private void DrawLeftSide() { ImGui.AlignTextToFramePadding(); + + if (_pair == null) + { + return; + } if (_pair.IsPaused) { @@ -246,7 +250,15 @@ public class DrawUserPair } else if (_pair.IsVisible) { - _uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue")); + if (_configService.Current.ShowVisiblePairsGreenEye) + { + _uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessGreen")); + } + else + { + _uiSharedService.IconText(FontAwesomeIcon.Eye, UIColors.Get("LightlessBlue")); + } + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped | ImGuiHoveredFlags.AllowWhenDisabled)) { _mediator.Publish(new PairFocusCharacterMessage(_pair)); diff --git a/LightlessSync/UI/DrawEntityFactory.cs b/LightlessSync/UI/DrawEntityFactory.cs index 1ecf3f5..3c71f5c 100644 --- a/LightlessSync/UI/DrawEntityFactory.cs +++ b/LightlessSync/UI/DrawEntityFactory.cs @@ -161,6 +161,7 @@ public class DrawEntityFactory _serverConfigurationManager, _uiSharedService, _playerPerformanceConfigService, + _configService, _charaDataManager, _pairLedger); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 8b79c53..d14e048 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1,6 +1,6 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Game.Text; using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; @@ -19,6 +19,7 @@ using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Services; using LightlessSync.UI.Style; @@ -38,7 +39,7 @@ using System.Net.Http.Json; using System.Numerics; using System.Text; using System.Text.Json; -using LightlessSync.Services.PairProcessing; +using static Penumbra.GameData.Files.ShpkFile; namespace LightlessSync.UI; @@ -1712,7 +1713,7 @@ public class SettingsUi : WindowMediatorSubscriberBase var groupedSyncshells = _configService.Current.ShowGroupedSyncshellsInAll; var groupInVisible = _configService.Current.ShowSyncshellUsersInVisible; var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; - + var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { @@ -2205,13 +2206,19 @@ public class SettingsUi : WindowMediatorSubscriberBase _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 pairs icon to an green color", ref greenVisiblePair)) + { + _configService.Current.ShowVisiblePairsGreenEye = greenVisiblePair; + _configService.Save(); + } + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); pairListTree.MarkContentEnd(); -- 2.49.1 From 25f0d41581b2c4a3f69297bc5a27ec02ecb22d02 Mon Sep 17 00:00:00 2001 From: cake Date: Sat, 6 Dec 2025 05:41:14 +0100 Subject: [PATCH 080/140] Fixed spelling --- LightlessSync/UI/SettingsUi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index d14e048..9602f5a 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -2213,7 +2213,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _configService.Save(); } - if (ImGui.Checkbox("Set visible pairs icon to an green color", ref greenVisiblePair)) + if (ImGui.Checkbox("Set visible pair icon to an green color", ref greenVisiblePair)) { _configService.Current.ShowVisiblePairsGreenEye = greenVisiblePair; _configService.Save(); -- 2.49.1 From 675918624de72201b2405ecb266f1c657c20e5fe Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 9 Dec 2025 05:45:19 +0100 Subject: [PATCH 081/140] Redone syncshell admin ui, fixed some bugs on edit profile. --- LightlessAPI | 2 +- LightlessSync/PlayerData/Pairs/PairManager.cs | 6 +- .../Pairs/VisibleUserDataDistributor.cs | 76 +-- LightlessSync/UI/CompactUI.cs | 24 +- LightlessSync/UI/DownloadUi.cs | 87 ++- LightlessSync/UI/DtrEntry.cs | 3 - LightlessSync/UI/EditProfileUi.Group.cs | 85 +-- LightlessSync/UI/EditProfileUi.cs | 73 +- LightlessSync/UI/StandaloneProfileUi.cs | 13 +- LightlessSync/UI/SyncshellAdminUI.cs | 627 +++++++++++++----- .../SignalR/ApiController.Functions.Groups.cs | 14 + LightlessSync/WebAPI/SignalR/HubFactory.cs | 18 +- 12 files changed, 684 insertions(+), 344 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index 0170ac3..dfb0594 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 0170ac377d7d2341c0d0e206ab871af22ac4767b +Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 diff --git a/LightlessSync/PlayerData/Pairs/PairManager.cs b/LightlessSync/PlayerData/Pairs/PairManager.cs index fc6844a..eb70a54 100644 --- a/LightlessSync/PlayerData/Pairs/PairManager.cs +++ b/LightlessSync/PlayerData/Pairs/PairManager.cs @@ -379,7 +379,8 @@ public sealed class PairManager dto.GroupPermissions, shell.GroupFullInfo.GroupUserPermissions, shell.GroupFullInfo.GroupUserInfo, - new Dictionary(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal)); + new Dictionary(shell.GroupFullInfo.GroupPairUserInfos, StringComparer.Ordinal), + 0); shell.Update(updated); return PairOperationResult.Ok(); @@ -514,7 +515,8 @@ public sealed class PairManager GroupPermissions.NoneSet, GroupUserPreferredPermissions.NoneSet, GroupPairUserInfo.None, - new Dictionary(StringComparer.Ordinal)); + new Dictionary(StringComparer.Ordinal), + 0); shell = new Syncshell(placeholder); _groups[group.GID] = shell; diff --git a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs index a1c7587..f71080a 100644 --- a/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs +++ b/LightlessSync/PlayerData/Pairs/VisibleUserDataDistributor.cs @@ -23,7 +23,7 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private readonly List _previouslyVisiblePlayers = []; private Task? _fileUploadTask = null; private readonly HashSet _usersToPushDataTo = new(UserDataComparer.Instance); - private readonly SemaphoreSlim _pushDataSemaphore = new(1, 1); + private readonly SemaphoreSlim _pushLock = new(1, 1); private readonly CancellationTokenSource _runtimeCts = new(); public VisibleUserDataDistributor(ILogger logger, ApiController apiController, DalamudUtilService dalamudUtil, @@ -108,53 +108,49 @@ public class VisibleUserDataDistributor : DisposableMediatorSubscriberBase private void PushCharacterData(bool forced = false) { if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) return; + _ = PushCharacterDataAsync(forced); + } - _ = Task.Run(async () => + private async Task PushCharacterDataAsync(bool forced = false) + { + await _pushLock.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); + try { - try - { - forced |= _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash; + if (_lastCreatedData == null || _usersToPushDataTo.Count == 0) + return; - if (_fileUploadTask == null || (_fileUploadTask?.IsCompleted ?? false) || forced) + var hashChanged = _uploadingCharacterData?.DataHash != _lastCreatedData.DataHash; + forced |= hashChanged; + + if (_fileUploadTask == null || _fileUploadTask.IsCompleted || forced) { _uploadingCharacterData = _lastCreatedData.DeepClone(); + var uploadTargets = _usersToPushDataTo.ToList(); Logger.LogDebug("Starting UploadTask for {hash}, Reason: TaskIsNull: {task}, TaskIsCompleted: {taskCpl}, Forced: {frc}", - _lastCreatedData.DataHash, _fileUploadTask == null, _fileUploadTask?.IsCompleted ?? false, forced); - _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, [.. _usersToPushDataTo]); + _lastCreatedData.DataHash, + _fileUploadTask == null, + _fileUploadTask?.IsCompleted ?? false, + forced); + + _fileUploadTask = _fileTransferManager.UploadFiles(_uploadingCharacterData, uploadTargets); } - if (_fileUploadTask != null) - { - var dataToSend = await _fileUploadTask.ConfigureAwait(false); - await _pushDataSemaphore.WaitAsync(_runtimeCts.Token).ConfigureAwait(false); - try - { - if (_usersToPushDataTo.Count == 0) return; - Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", _usersToPushDataTo.Select(k => k.AliasOrUID))); - await _apiController.PushCharacterData(dataToSend, [.. _usersToPushDataTo]).ConfigureAwait(false); - _usersToPushDataTo.Clear(); - } - finally - { - _pushDataSemaphore.Release(); - } - } - } - catch (OperationCanceledException) when (_runtimeCts.IsCancellationRequested) - { - Logger.LogDebug("PushCharacterData cancelled"); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to push character data"); - } - }); + var dataToSend = await _fileUploadTask.ConfigureAwait(false); + + var users = _usersToPushDataTo.ToList(); + if (users.Count == 0) + return; + + Logger.LogDebug("Pushing {data} to {users}", dataToSend.DataHash, string.Join(", ", users.Select(k => k.AliasOrUID))); + + await _apiController.PushCharacterData(dataToSend, users).ConfigureAwait(false); + _usersToPushDataTo.Clear(); + } + finally + { + _pushLock.Release(); + } } - private List GetVisibleUsers() - { - return _pairLedger.GetVisiblePairs() - .Select(connection => connection.User) - .ToList(); - } + private List GetVisibleUsers() => [.. _pairLedger.GetVisiblePairs().Select(connection => connection.User)]; } diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index a40dd1b..65c6dfa 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -957,11 +957,10 @@ public class CompactUi : WindowMediatorSubscriberBase private ImmutableList SortEntries(IEnumerable entries) { - return entries + return [.. entries .OrderByDescending(e => e.IsVisible) .ThenByDescending(e => e.IsOnline) - .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) - .ToImmutableList(); + .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortVisibleEntries(IEnumerable entries) @@ -972,9 +971,7 @@ public class CompactUi : WindowMediatorSubscriberBase VisiblePairSortMode.VramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateVramBytes), VisiblePairSortMode.EffectiveVramUsage => SortVisibleByMetric(entryList, e => e.LastAppliedApproximateEffectiveVramBytes), VisiblePairSortMode.TriangleCount => SortVisibleByMetric(entryList, e => e.LastAppliedDataTris), - VisiblePairSortMode.Alphabetical => entryList - .OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) - .ToImmutableList(), + VisiblePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)], VisiblePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), _ => SortEntries(entryList), }; @@ -982,31 +979,28 @@ public class CompactUi : WindowMediatorSubscriberBase private ImmutableList SortVisibleByMetric(IEnumerable entries, Func selector) { - return entries + return [.. entries .OrderByDescending(entry => selector(entry) >= 0) .ThenByDescending(selector) .ThenByDescending(entry => entry.IsOnline) - .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase) - .ToImmutableList(); + .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortVisibleByPreferred(IEnumerable entries) { - return entries + return [.. entries .OrderByDescending(entry => entry.IsDirectlyPaired && entry.SelfPermissions.IsSticky()) .ThenByDescending(entry => entry.IsDirectlyPaired) .ThenByDescending(entry => entry.IsOnline) - .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase) - .ToImmutableList(); + .ThenBy(entry => AlphabeticalSortKey(entry), StringComparer.OrdinalIgnoreCase)]; } private ImmutableList SortGroupEntries(IEnumerable entries, GroupFullInfoDto group) { - return entries + return [.. entries .OrderByDescending(e => e.IsOnline) .ThenBy(e => GroupSortWeight(e, group)) - .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase) - .ToImmutableList(); + .ThenBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)]; } private int GroupSortWeight(PairUiEntry entry, GroupFullInfoDto group) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index dac49c1..37119e2 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -103,7 +103,7 @@ public class DownloadUi : WindowMediatorSubscriberBase // Check if download notifications are enabled (not set to TextOverlay) var useNotifications = _configService.Current.UseLightlessNotifications - ? _configService.Current.LightlessDownloadNotification != NotificationLocation.TextOverlay + ? _configService.Current.LightlessDownloadNotification != NotificationLocation.LightlessUi : _configService.Current.UseNotificationsForDownloads; if (useNotifications) @@ -534,6 +534,7 @@ public class DownloadUi : WindowMediatorSubscriberBase if (lineSize.X > contentWidth) contentWidth = lineSize.X; } + } var lineHeight = ImGui.GetTextLineHeight(); @@ -635,32 +636,40 @@ public class DownloadUi : WindowMediatorSubscriberBase foreach (var p in orderedPlayers) { - var playerSpeedText = p.SpeedBytesPerSecond > 0 + var hasSpeed = p.SpeedBytesPerSecond > 0; + var playerSpeedText = hasSpeed ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" : "-"; + // Label line for the player var labelLine = $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; - if (!_configService.Current.ShowPlayerSpeedBarsTransferWindow || p.DlProg <= 0) - { - var fullLine = - $"{labelLine} " + - $"({UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}) " + - $"@ {playerSpeedText}"; + // State flags + var isDownloading = p.DlProg > 0; + var isDecompressing = p.DlDecomp > 0 + || (!isDownloading && p.TotalBytes > 0 && p.TransferredBytes >= p.TotalBytes); + + var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow + && (isDownloading || isDecompressing); + + if (!showBar) + { UiSharedService.DrawOutlinedFont( drawList, - fullLine, + labelLine, cursor, UiSharedService.Color(255, 255, 255, _transferBoxTransparency), UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); + cursor.Y += lineHeight + spacingY; continue; } + // Top label line (only name + W/Q/P/D + files) UiSharedService.DrawOutlinedFont( drawList, labelLine, @@ -671,6 +680,7 @@ public class DownloadUi : WindowMediatorSubscriberBase ); cursor.Y += lineHeight + spacingY; + // Bar background var barBgMin = new Vector2(boxMin.X + padding, cursor.Y); var barBgMax = new Vector2(boxMax.X - padding, cursor.Y + perPlayerBarHeight); @@ -682,8 +692,14 @@ public class DownloadUi : WindowMediatorSubscriberBase ); float ratio = 0f; - if (maxSpeed > 0) - ratio = (float)(p.SpeedBytesPerSecond / maxSpeed); + if (isDownloading && p.TotalBytes > 0) + { + ratio = (float)p.TransferredBytes / p.TotalBytes; + } + else if (isDecompressing) + { + ratio = 1f; + } if (ratio < 0f) ratio = 0f; if (ratio > 1f) ratio = 1f; @@ -698,24 +714,43 @@ public class DownloadUi : WindowMediatorSubscriberBase 3f ); - var barText = - $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)} @ {playerSpeedText}"; + string barText; - var barTextSize = ImGui.CalcTextSize(barText); + if (isDownloading) + { + var bytesInside = + $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}"; - var barTextPos = new Vector2( - barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, - barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 - ); + barText = hasSpeed + ? $"{bytesInside} @ {playerSpeedText}" + : bytesInside; + } + else if (isDecompressing) + { + barText = "Decompressing..."; + } + else + { + barText = string.Empty; + } - UiSharedService.DrawOutlinedFont( - drawList, - barText, - barTextPos, - UiSharedService.Color(255, 255, 255, _transferBoxTransparency), - UiSharedService.Color(0, 0, 0, _transferBoxTransparency), - 1 - ); + if (!string.IsNullOrEmpty(barText)) + { + var barTextSize = ImGui.CalcTextSize(barText); + var barTextPos = new Vector2( + barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, + barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 + ); + + UiSharedService.DrawOutlinedFont( + drawList, + barText, + barTextPos, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + } cursor.Y += perPlayerBarHeight + spacingY; } diff --git a/LightlessSync/UI/DtrEntry.cs b/LightlessSync/UI/DtrEntry.cs index 770331e..9cadb4c 100644 --- a/LightlessSync/UI/DtrEntry.cs +++ b/LightlessSync/UI/DtrEntry.cs @@ -2,7 +2,6 @@ using Dalamud.Game.Gui.Dtr; using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Plugin.Services; -using Dalamud.Utility; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.Services; @@ -13,11 +12,9 @@ using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; using System.Runtime.InteropServices; using System.Text; using LightlessSync.UI.Services; -using LightlessSync.PlayerData.Pairs; using static LightlessSync.Services.PairRequestService; using LightlessSync.Services.LightFinder; diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index 6e8f6d3..f93ba70 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -2,26 +2,17 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Components; -using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; -using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; -using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.Profiles; using LightlessSync.UI.Tags; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.PixelFormats; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Numerics; -using System.Threading.Tasks; namespace LightlessSync.UI; @@ -68,6 +59,7 @@ public partial class EditProfileUi _bannerTextureWrap = null; _showProfileImageError = false; _showBannerImageError = false; + _groupVisibilityInitialized = false; } private void DrawGroupEditor(float scale) @@ -376,6 +368,8 @@ public partial class EditProfileUi private void DrawGroupProfileVisibilityControls() { + EnsureGroupVisibilityStateInitialised(); + bool changedNsfw = DrawCheckboxRow("Profile is NSFW", _groupIsNsfw, out var newNsfw, "Flag this profile as not safe for work."); if (changedNsfw) _groupIsNsfw = newNsfw; @@ -504,33 +498,36 @@ public partial class EditProfileUi try { var fileContent = await File.ReadAllBytesAsync(filePath).ConfigureAwait(false); - await using var stream = new MemoryStream(fileContent); - var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); - if (!IsSupportedImageFormat(format)) + var stream = new MemoryStream(fileContent); + await using (stream.ConfigureAwait(false)) { - _showBannerImageError = true; - return; + var format = await Image.DetectFormatAsync(stream).ConfigureAwait(false); + if (!IsSupportedImageFormat(format)) + { + _showBannerImageError = true; + return; + } + + using var image = Image.Load(fileContent); + if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) + { + _showBannerImageError = true; + return; + } + + await _apiController.GroupSetProfile(new GroupProfileDto( + _groupInfo.Group, + Description: null, + Tags: null, + PictureBase64: null, + BannerBase64: Convert.ToBase64String(fileContent), + IsNsfw: null, + IsDisabled: null)).ConfigureAwait(false); + + _showBannerImageError = false; + _queuedBannerImage = fileContent; + Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } - - using var image = Image.Load(fileContent); - if (image.Width > 840 || image.Height > 260 || fileContent.Length > 2000 * 1024) - { - _showBannerImageError = true; - return; - } - - await _apiController.GroupSetProfile(new GroupProfileDto( - _groupInfo.Group, - Description: null, - Tags: null, - PictureBase64: null, - BannerBase64: Convert.ToBase64String(fileContent), - IsNsfw: null, - IsDisabled: null)).ConfigureAwait(false); - - _showBannerImageError = false; - _queuedBannerImage = fileContent; - Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) { @@ -588,6 +585,16 @@ public partial class EditProfileUi } } + private void EnsureGroupVisibilityStateInitialised() + { + if (_groupInfo == null || _groupVisibilityInitialized) + return; + + _groupIsNsfw = _groupServerIsNsfw; + _groupIsDisabled = _groupServerIsDisabled; + _groupVisibilityInitialized = true; + } + private async Task SubmitGroupTagChanges(int[] payload) { if (_groupInfo is null) @@ -695,11 +702,15 @@ public partial class EditProfileUi } } - _groupIsNsfw = profile.IsNsfw; - _groupIsDisabled = profile.IsDisabled; _groupServerIsNsfw = profile.IsNsfw; _groupServerIsDisabled = profile.IsDisabled; - } + if (!_groupVisibilityInitialized) + { + _groupIsNsfw = _groupServerIsNsfw; + _groupIsDisabled = _groupServerIsDisabled; + _groupVisibilityInitialized = true; + } + } } diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 78c38ed..4a5bd84 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -43,7 +43,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase " - toggle style flags.\n" + " - create clickable links."; - private static readonly HashSet SupportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet _supportedImageExtensions = new(StringComparer.OrdinalIgnoreCase) { "png", "jpg", @@ -52,7 +52,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase "bmp" }; private const string _imageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; - private readonly List _tagEditorSelection = new(); + private readonly List _tagEditorSelection = []; private int[] _profileTagIds = []; private readonly List _tagPreviewSegments = new(); private enum ProfileEditorMode @@ -68,6 +68,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private bool _groupIsDisabled; private bool _groupServerIsNsfw; private bool _groupServerIsDisabled; + private bool _groupVisibilityInitialized; private byte[]? _queuedProfileImage; private byte[]? _queuedBannerImage; private readonly Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); @@ -85,6 +86,9 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private bool _showProfileImageError = false; private bool _showBannerImageError = false; private bool _wasOpen; + private bool _userServerIsNsfw; + private bool _isNsfwInitialized; + private bool _isNsfw; private Vector4 _currentBg = new(0.15f, 0.15f, 0.15f, 1f); private bool _textEnabled; @@ -171,6 +175,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase return; } + _isNsfwInitialized = false; + var user = await EnsureSelfProfileUserDataAsync().ConfigureAwait(false); if (user is not null) { @@ -339,13 +345,12 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } SyncProfileState(profile); - DrawSection("Profile Preview", scale, () => DrawProfileSnapshot(profile, scale)); DrawSection("Profile Image", scale, () => DrawProfileImageControls(profile, scale)); DrawSection("Profile Banner", scale, () => DrawProfileBannerControls(profile, scale)); DrawSection("Profile Description", scale, () => DrawProfileDescriptionEditor(profile, scale)); DrawSection("Profile Tags", scale, () => DrawProfileTagsEditor(profile, scale)); - DrawSection("Visibility", scale, () => DrawProfileVisibilityControls(profile)); + DrawSection("Visibility", scale, () => DrawProfileVisibilityControls()); } private void DrawProfileSnapshot(LightlessUserProfileData profile, float scale) @@ -877,21 +882,46 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase UiSharedService.AttachToolTip(saveTooltip); } - private void DrawProfileVisibilityControls(LightlessUserProfileData profile) + private void DrawProfileVisibilityControls() { - var isNsfw = profile.IsNSFW; - if (DrawCheckboxRow("Mark profile as NSFW", isNsfw, out var newValue, "Enable when your profile could be considered NSFW.")) + if (!_isNsfwInitialized) + ImGui.BeginDisabled(); + + bool changed = DrawCheckboxRow("Mark profile as NSFW", _isNsfw, out var newValue, "Enable when your profile could be considered NSFW."); + + if (changed) + _isNsfw = newValue; + + bool visibilityChanged = _isNsfwInitialized && (_isNsfw != _userServerIsNsfw); + + if (!_isNsfwInitialized) + ImGui.EndDisabled(); + + if (!_isNsfwInitialized || !visibilityChanged) + ImGui.BeginDisabled(); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Save, "Apply Visibility Changes")) { + _userServerIsNsfw = _isNsfw; + _ = _apiController.UserSetProfile(new UserProfileDto( new UserData(_apiController.UID), Disabled: false, - newValue, - ProfilePictureBase64: GetCurrentProfilePictureBase64(profile), + IsNSFW: _isNsfw, + ProfilePictureBase64: null, + BannerPictureBase64: null, Description: null, - BannerPictureBase64: GetCurrentProfileBannerBase64(profile), - Tags: GetServerTagPayload())); + Tags: null)); } + UiSharedService.AttachToolTip("Apply the visibility toggles above."); + + if (!_isNsfwInitialized || !visibilityChanged) + ImGui.EndDisabled(); + + ImGui.SameLine(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.SyncAlt, "Reset") && _isNsfwInitialized) + _isNsfw = _userServerIsNsfw; } private string? GetCurrentProfilePictureBase64(LightlessUserProfileData profile) @@ -932,7 +962,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase foreach (var ext in format.FileExtensions) { - if (SupportedImageExtensions.Contains(ext)) + if (_supportedImageExtensions.Contains(ext)) return true; } @@ -1183,7 +1213,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase }); } - private void DrawSection(string title, float scale, Action body) + private static void DrawSection(string title, float scale, Action body) { ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(6f, 4f) * scale); @@ -1199,9 +1229,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } } - private bool DrawCheckboxRow(string label, bool currentValue, out bool newValue, string? tooltip = null) + private static bool DrawCheckboxRow(string label, bool currentValue, out bool newValue, string? tooltip = null) { - bool value = currentValue; bool changed = UiSharedService.CheckboxWithBorder(label, ref value, UIColors.Get("LightlessPurple"), 1.5f); if (!string.IsNullOrEmpty(tooltip)) @@ -1214,7 +1243,17 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase private void SyncProfileState(LightlessUserProfileData profile) { if (string.Equals(profile.Description, LoadingProfileDescription, StringComparison.Ordinal)) + { + _isNsfwInitialized = false; return; + } + + if (!_isNsfwInitialized) + { + _userServerIsNsfw = profile.IsNSFW; + _isNsfw = profile.IsNSFW; + _isNsfwInitialized = true; + } var profileBytes = profile.ImageData.Value; if (_pfpTextureWrap == null || !_profileImage.SequenceEqual(profileBytes)) @@ -1239,11 +1278,11 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase _descriptionText = _profileDescription; } - var serverTags = profile.Tags ?? Array.Empty(); + var serverTags = profile.Tags ?? []; if (!TagsEqual(serverTags, _profileTagIds)) { var previous = _profileTagIds; - _profileTagIds = serverTags.Count == 0 ? Array.Empty() : serverTags.ToArray(); + _profileTagIds = serverTags.Count == 0 ? [] : [.. serverTags]; if (TagsEqual(_tagEditorSelection, previous)) { diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index f2e805e..eb694aa 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -184,7 +184,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase var profile = _lightlessProfileManager.GetLightlessProfile(userData); IReadOnlyList profileTags = profile.Tags.Count > 0 ? ProfileTagService.ResolveTags(profile.Tags) - : Array.Empty(); + : []; if (_textureWrap == null || !profile.ImageData.Value.SequenceEqual(_lastProfilePicture)) { @@ -225,7 +225,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase bool directPair = false; bool youPaused = false; bool theyPaused = false; - List syncshellLines = new(); + List syncshellLines = []; if (!_isLightfinderContext && Pair != null) { @@ -245,7 +245,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase theyPaused = pairInfo.OtherPermissions.IsPaused(); } - if (pairInfo.Groups.Any()) + if (pairInfo.Groups.Count != 0) { foreach (var gid in pairInfo.Groups) { @@ -276,8 +276,11 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase presenceTokens.Add(new PresenceToken("They paused syncing", true)); } + if (profile.IsNSFW) + presenceTokens.Add(new PresenceToken("NSFW", Emphasis: true)); + if (syncshellLines.Count > 0) - presenceTokens.Add(new PresenceToken($"Sharing Syncshells ({syncshellLines.Count})", false, syncshellLines, "Shared Syncshells")); + presenceTokens.Add(new PresenceToken($"Sharing Syncshells ({syncshellLines.Count})", Emphasis: false, syncshellLines, "Shared Syncshells")); var drawList = ImGui.GetWindowDrawList(); var style = ImGui.GetStyle(); @@ -780,7 +783,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase }; if (profile.IsNsfw) - presenceTokens.Add(new PresenceToken("NSFW", true)); + presenceTokens.Add(new PresenceToken("NSFW", Emphasis: true)); int memberCount = 0; List? groupMembers = null; diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 45b97a6..66765d4 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -12,7 +12,6 @@ using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.Profiles; using LightlessSync.UI.Services; -using LightlessSync.UI.Style; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; @@ -42,6 +41,11 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private Task? _pruneTask; private int _pruneDays = 14; + private Task? _pruneSettingsTask; + private bool _pruneSettingsLoaded; + private bool _autoPruneEnabled; + private int _autoPruneDays = 14; + public SyncshellAdminUI(ILogger logger, LightlessMediator mediator, ApiController apiController, UiSharedService uiSharedService, PairUiService pairUiService, GroupFullInfoDto groupFullInfo, PerformanceCollectorService performanceCollectorService, LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Syncshell Admin Panel (" + groupFullInfo.GroupAliasOrGID + ")", performanceCollectorService) @@ -89,36 +93,147 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase _profileData = _lightlessProfileManager.GetLightlessGroupProfile(GroupFullInfo.Group); using var id = ImRaii.PushId("syncshell_admin_" + GroupFullInfo.GID); - using (_uiSharedService.UidFont.Push()) - { - var headerText = $"{GroupFullInfo.GroupAliasOrGID} Administrative Panel"; - _uiSharedService.UnderlinedBigText(headerText, UIColors.Get("LightlessBlue")); - } + DrawAdminHeader(); + + ImGui.Separator(); + var perm = GroupFullInfo.GroupPermissions; + + DrawAdminTopBar(perm); + } + + private void DrawAdminHeader() + { + float scale = ImGuiHelpers.GlobalScale; + var style = ImGui.GetStyle(); + + var cursorLocal = ImGui.GetCursorPos(); + var pMin = ImGui.GetCursorScreenPos(); + float width = ImGui.GetContentRegionAvail().X; + float height = 64f * scale; + + var pMax = new Vector2(pMin.X + width, pMin.Y + height); + var drawList = ImGui.GetWindowDrawList(); + + var purple = UIColors.Get("LightlessPurple"); + var gradLeft = purple.WithAlpha(0.0f); + var gradRight = purple.WithAlpha(0.85f); + + uint colTopLeft = ImGui.ColorConvertFloat4ToU32(gradLeft); + uint colTopRight = ImGui.ColorConvertFloat4ToU32(gradRight); + uint colBottomRight = ImGui.ColorConvertFloat4ToU32(gradRight); + uint colBottomLeft = ImGui.ColorConvertFloat4ToU32(gradLeft); + + drawList.AddRectFilledMultiColor( + pMin, + pMax, + colTopLeft, + colTopRight, + colBottomRight, + colBottomLeft); + + float accentHeight = 3f * scale; + var accentMin = new Vector2(pMin.X, pMax.Y - accentHeight); + var accentMax = new Vector2(pMax.X, pMax.Y); + var accentColor = UIColors.Get("LightlessBlue"); + uint accentU32 = ImGui.ColorConvertFloat4ToU32(accentColor); + drawList.AddRectFilled(accentMin, accentMax, accentU32); + + ImGui.InvisibleButton("##adminHeaderHitbox", new Vector2(width, height)); if (ImGui.IsItemHovered()) { ImGui.BeginTooltip(); ImGui.Text($"{GroupFullInfo.GroupAliasOrGID} is created at:"); ImGui.Separator(); - ImGui.Text(text: GroupFullInfo.Group.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")); + ImGui.Text(GroupFullInfo.Group.CreatedAt?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "Unknown"); ImGui.EndTooltip(); } - ImGui.Separator(); - var perm = GroupFullInfo.GroupPermissions; + var titlePos = new Vector2(pMin.X + 12f * scale, pMin.Y + 8f * scale); + ImGui.SetCursorScreenPos(titlePos); - using var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID); - - if (tabbar) + float titleHeight; + using (_uiSharedService.UidFont.Push()) { - DrawInvites(perm); - - DrawManagement(); - - DrawPermission(perm); - - DrawProfile(); + ImGui.TextColored(UIColors.Get("LightlessBlue"), GroupFullInfo.GroupAliasOrGID); + titleHeight = ImGui.GetTextLineHeightWithSpacing(); } + + var subtitlePos = new Vector2( + pMin.X + 12f * scale, + titlePos.Y + titleHeight - 2f * scale); + + ImGui.SetCursorScreenPos(subtitlePos); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.TextUnformatted("Administrative Panel"); + ImGui.PopStyleColor(); + + string roleLabel = _isOwner ? "Owner" : (_isModerator ? "Moderator" : string.Empty); + if (!string.IsNullOrEmpty(roleLabel)) + { + float roleTextW = ImGui.CalcTextSize(roleLabel).X; + float pillPaddingX = 8f * scale; + float pillPaddingY = -1f * scale; + + float pillWidth = roleTextW + pillPaddingX * 2f; + float pillHeight = ImGui.GetTextLineHeight() + pillPaddingY * 2f; + + var pillMin = new Vector2( + pMax.X - pillWidth - style.WindowPadding.X, + subtitlePos.Y - pillPaddingY); + var pillMax = new Vector2(pillMin.X + pillWidth, pillMin.Y + pillHeight); + + var pillBg = _isOwner ? UIColors.Get("LightlessYellow") : UIColors.Get("LightlessOrange"); + uint pillBgU = ImGui.ColorConvertFloat4ToU32(pillBg.WithAlpha(0.9f)); + + drawList.AddRectFilled(pillMin, pillMax, pillBgU, 8f * scale); + + ImGui.SetCursorScreenPos(new Vector2(pillMin.X + pillPaddingX, pillMin.Y + pillPaddingY)); + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("FullBlack")); + ImGui.TextUnformatted(roleLabel); + ImGui.PopStyleColor(); + } + + ImGui.SetCursorPos(new Vector2(cursorLocal.X, cursorLocal.Y + height + 6f * scale)); + } + + private void DrawAdminTopBar(GroupPermissions perm) + { + var style = ImGui.GetStyle(); + + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(12f, 6f) * ImGuiHelpers.GlobalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(10f, style.ItemSpacing.Y)); + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 6f * ImGuiHelpers.GlobalScale); + + var baseTab = UIColors.Get("FullBlack").WithAlpha(0.0f); + var baseTabDim = UIColors.Get("FullBlack").WithAlpha(0.1f); + var accent = UIColors.Get("LightlessPurple"); + var accentHover = accent.WithAlpha(0.90f); + var accentActive = accent; + + ImGui.PushStyleColor(ImGuiCol.Tab, baseTab); + ImGui.PushStyleColor(ImGuiCol.TabHovered, accentHover); + ImGui.PushStyleColor(ImGuiCol.TabActive, accentActive); + ImGui.PushStyleColor(ImGuiCol.TabUnfocused, baseTabDim); + ImGui.PushStyleColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)); + + using (var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID)) + { + if (tabbar) + { + DrawInvites(perm); + DrawManagement(); + DrawPermission(perm); + DrawProfile(); + } + } + + ImGui.PopStyleColor(5); + ImGui.PopStyleVar(3); + + ImGuiHelpers.ScaledDummy(2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); + ImGuiHelpers.ScaledDummy(2f); } private void DrawPermission(GroupPermissions perm) @@ -218,6 +333,70 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private void DrawAutoPruneSettings() + { + ImGuiHelpers.ScaledDummy(2f); + UiSharedService.TextWrapped("Automatic prune (server-side scheduled cleanup of inactive users)."); + + _pruneSettingsTask ??= _apiController.GroupGetPruneSettings(new GroupDto(GroupFullInfo.Group)); + + if (!_pruneSettingsLoaded) + { + if (!_pruneSettingsTask!.IsCompleted) + { + UiSharedService.ColorTextWrapped("Loading prune settings from server...", ImGuiColors.DalamudGrey); + return; + } + + if (_pruneSettingsTask.IsFaulted || _pruneSettingsTask.IsCanceled) + { + UiSharedService.ColorTextWrapped("Failed to load auto-prune settings.", ImGuiColors.DalamudRed); + _pruneSettingsTask = null; + _pruneSettingsLoaded = false; + return; + } + + var dto = _pruneSettingsTask.GetAwaiter().GetResult(); + + _autoPruneEnabled = dto.AutoPruneEnabled && dto.AutoPruneDays > 0; + _autoPruneDays = dto.AutoPruneDays > 0 ? dto.AutoPruneDays : 14; + + _pruneSettingsLoaded = true; + } + + bool enabled = _autoPruneEnabled; + if (ImGui.Checkbox("Enable automatic pruning", ref enabled)) + { + _autoPruneEnabled = enabled; + SavePruneSettings(); + } + UiSharedService.AttachToolTip("When enabled, inactive non-pinned, non-moderator users will be pruned automatically on the server."); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(150); + + using (ImRaii.Disabled(!_autoPruneEnabled)) + { + _uiSharedService.DrawCombo( + "Day(s) of inactivity", + [1, 3, 7, 14, 30, 90], + days => $"{days} day(s)", + selected => + { + _autoPruneDays = selected; + SavePruneSettings(); + }, + _autoPruneDays); + } + + if (!_autoPruneEnabled) + { + UiSharedService.ColorTextWrapped( + "Automatic prune is currently disabled. Enable it and choose an inactivity threshold to let the server clean up inactive users automatically.", + ImGuiColors.DalamudGrey); + } + } + private void DrawProfile() { var profileTab = ImRaii.TabItem("Profile"); @@ -268,167 +447,230 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private void DrawManagement() { var mgmtTab = ImRaii.TabItem("User Management"); - if (mgmtTab) + if (!mgmtTab) + return; + + ImGuiHelpers.ScaledDummy(3f); + + var style = ImGui.GetStyle(); + + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(10f, 5f) * ImGuiHelpers.GlobalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8f, style.ItemSpacing.Y)); + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 5f * ImGuiHelpers.GlobalScale); + + var baseTab = UIColors.Get("FullBlack").WithAlpha(0.0f); + var baseTabDim = UIColors.Get("FullBlack").WithAlpha(0.1f); + var accent = UIColors.Get("LightlessPurple"); + var accentHover = accent.WithAlpha(0.90f); + var accentActive = accent; + + ImGui.PushStyleColor(ImGuiCol.Tab, baseTab); + ImGui.PushStyleColor(ImGuiCol.TabHovered, accentHover); + ImGui.PushStyleColor(ImGuiCol.TabActive, accentActive); + ImGui.PushStyleColor(ImGuiCol.TabUnfocused, baseTabDim); + ImGui.PushStyleColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)); + + using (var innerTabBar = ImRaii.TabBar("user_mgmt_inner_tab_" + GroupFullInfo.GID)) { - if (_uiSharedService.MediumTreeNode("User List & Administration", UIColors.Get("LightlessPurple"))) + if (innerTabBar) { - var snapshot = _pairUiService.GetSnapshot(); - if (!snapshot.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + // Users tab + var usersTab = ImRaii.TabItem("Users"); + if (usersTab) { - UiSharedService.ColorTextWrapped("No users found in this Syncshell", ImGuiColors.DalamudYellow); - } - else - { - DrawUserListCustom(pairs, GroupFullInfo); + DrawUserListSection(); } + usersTab.Dispose(); - ImGui.TreePop(); + // Cleanup tab + var cleanupTab = ImRaii.TabItem("Cleanup"); + if (cleanupTab) + { + DrawMassCleanupSection(); + } + cleanupTab.Dispose(); + + // Bans tab + var bansTab = ImRaii.TabItem("Bans"); + if (bansTab) + { + DrawUserBansSection(); + } + bansTab.Dispose(); } - ImGui.Separator(); - - if (_uiSharedService.MediumTreeNode("Mass Cleanup", UIColors.Get("DimRed"))) - { - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear Syncshell")) - { - _ = _apiController.GroupClear(new(GroupFullInfo.Group)); - } - } - UiSharedService.AttachToolTip("This will remove all non-pinned, non-moderator users from the Syncshell." - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - - ImGui.SameLine(); - - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Brush, "Clear Lightfinder Users")) - { - _ = _apiController.GroupClearFinder(new(GroupFullInfo.Group)); - } - } - UiSharedService.AttachToolTip("This will remove all users that joined through Lightfinder from the Syncshell." - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - - ImGuiHelpers.ScaledDummy(2f); - ImGui.Separator(); - ImGuiHelpers.ScaledDummy(2f); - - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Unlink, "Check for Inactive Users")) - { - _pruneTestTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: false); - _pruneTask = null; - } - UiSharedService.AttachToolTip($"This will start the prune process for this Syncshell of inactive Lightless users that have not logged in in the past {_pruneDays} day(s)." - + Environment.NewLine + "You will be able to review the amount of inactive users before executing the prune." - + UiSharedService.TooltipSeparator + "Note: this check excludes pinned users and moderators of this Syncshell."); - ImGui.SameLine(); - ImGui.SetNextItemWidth(150); - _uiSharedService.DrawCombo( - "Day(s) of inactivity", - [0, 1, 3, 7, 14, 30, 90], - (count) => - { - return count == 0 ? "15 minute(s)" : count + " day(s)"; - }, - (selected) => - { - _pruneDays = selected; - _pruneTestTask = null; - _pruneTask = null; - }, - _pruneDays); - - if (_pruneTestTask != null) - { - if (!_pruneTestTask.IsCompleted) - { - UiSharedService.ColorTextWrapped("Calculating inactive users...", ImGuiColors.DalamudYellow); - } - else - { - ImGui.AlignTextToFramePadding(); - UiSharedService.TextWrapped($"Found {_pruneTestTask.Result} user(s) that have not logged into Lightless in the past {_pruneDays} day(s)."); - if (_pruneTestTask.Result > 0) - { - using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Prune Inactive Users")) - { - _pruneTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: true); - _pruneTestTask = null; - } - } - UiSharedService.AttachToolTip($"Pruning will remove {_pruneTestTask?.Result ?? 0} inactive user(s)." - + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - } - } - } - if (_pruneTask != null) - { - if (!_pruneTask.IsCompleted) - { - UiSharedService.ColorTextWrapped("Pruning Syncshell...", ImGuiColors.DalamudYellow); - } - else - { - UiSharedService.TextWrapped($"Syncshell was pruned and {_pruneTask.Result} inactive user(s) have been removed."); - } - } - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); - ImGui.TreePop(); - } - ImGui.Separator(); - - if (_uiSharedService.MediumTreeNode("User Bans", UIColors.Get("LightlessYellow"))) - { - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server")) - { - _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; - } - var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; - if (_bannedUsers.Count > 10) tableFlags |= ImGuiTableFlags.ScrollY; - if (ImGui.BeginTable("bannedusertable" + GroupFullInfo.GID, 6, tableFlags)) - { - ImGui.TableSetupColumn("UID", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("Alias", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("By", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("Date", ImGuiTableColumnFlags.None, 2); - ImGui.TableSetupColumn("Reason", ImGuiTableColumnFlags.None, 3); - ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.None, 1); - - ImGui.TableHeadersRow(); - - foreach (var bannedUser in _bannedUsers.ToList()) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.UID); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.UserAlias ?? string.Empty); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.BannedBy); - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture)); - ImGui.TableNextColumn(); - UiSharedService.TextWrapped(bannedUser.Reason); - ImGui.TableNextColumn(); - using var _ = ImRaii.PushId(bannedUser.UID); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) - { - _apiController.GroupUnbanUser(bannedUser); - _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); - } - } - ImGui.EndTable(); - } - UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); - ImGui.TreePop(); - } - ImGui.Separator(); } mgmtTab.Dispose(); } + private void DrawUserListSection() + { + var snapshot = _pairUiService.GetSnapshot(); + if (!snapshot.GroupPairs.TryGetValue(GroupFullInfo, out var pairs)) + { + UiSharedService.ColorTextWrapped("No users found in this Syncshell", ImGuiColors.DalamudYellow); + return; + } + + _uiSharedService.MediumText("User List & Administration", UIColors.Get("LightlessPurple")); + ImGuiHelpers.ScaledDummy(2f); + DrawUserListCustom(pairs, GroupFullInfo); + } + + private void DrawMassCleanupSection() + { + _uiSharedService.MediumText("Mass Cleanup", UIColors.Get("DimRed")); + UiSharedService.TextWrapped("Tools to bulk-clean inactive or unwanted users from this Syncshell."); + ImGuiHelpers.ScaledDummy(3f); + + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Clear Syncshell")) + { + _ = _apiController.GroupClear(new(GroupFullInfo.Group)); + } + } + UiSharedService.AttachToolTip("This will remove all non-pinned, non-moderator users from the Syncshell." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + + ImGui.SameLine(); + + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Brush, "Clear Lightfinder Users")) + { + _ = _apiController.GroupClearFinder(new(GroupFullInfo.Group)); + } + } + UiSharedService.AttachToolTip("This will remove all users that joined through Lightfinder from the Syncshell." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + + ImGuiHelpers.ScaledDummy(2f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + ImGuiHelpers.ScaledDummy(2f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Unlink, "Check for Inactive Users")) + { + _pruneTestTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: false); + _pruneTask = null; + } + UiSharedService.AttachToolTip($"This will start the prune process for this Syncshell of inactive Lightless users that have not logged in in the past {_pruneDays} day(s)." + + Environment.NewLine + "You will be able to review the amount of inactive users before executing the prune." + + UiSharedService.TooltipSeparator + "Note: this check excludes pinned users and moderators of this Syncshell."); + + ImGui.SameLine(); + ImGui.SetNextItemWidth(150); + _uiSharedService.DrawCombo( + "Day(s) of inactivity", + [0, 1, 3, 7, 14, 30, 90], + (count) => count == 0 ? "15 minute(s)" : count + " day(s)", + (selected) => + { + _pruneDays = selected; + _pruneTestTask = null; + _pruneTask = null; + }, + _pruneDays); + + if (_pruneTestTask != null) + { + if (!_pruneTestTask.IsCompleted) + { + UiSharedService.ColorTextWrapped("Calculating inactive users...", ImGuiColors.DalamudYellow); + } + else + { + ImGui.AlignTextToFramePadding(); + UiSharedService.TextWrapped($"Found {_pruneTestTask.Result} user(s) that have not logged into Lightless in the past {_pruneDays} day(s)."); + if (_pruneTestTask.Result > 0) + { + using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) + { + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Broom, "Prune Inactive Users")) + { + _pruneTask = _apiController.GroupPrune(new(GroupFullInfo.Group), _pruneDays, execute: true); + _pruneTestTask = null; + } + } + UiSharedService.AttachToolTip($"Pruning will remove {_pruneTestTask?.Result ?? 0} inactive user(s)." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + } + } + } + + if (_pruneTask != null) + { + if (!_pruneTask.IsCompleted) + { + UiSharedService.ColorTextWrapped("Pruning Syncshell...", ImGuiColors.DalamudYellow); + } + else + { + UiSharedService.TextWrapped($"Syncshell was pruned and {_pruneTask.Result} inactive user(s) have been removed."); + } + } + + ImGuiHelpers.ScaledDummy(4f); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + ImGuiHelpers.ScaledDummy(2f); + + DrawAutoPruneSettings(); + } + + private void DrawUserBansSection() + { + _uiSharedService.MediumText("User Bans", UIColors.Get("LightlessYellow")); + ImGuiHelpers.ScaledDummy(3f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Retweet, "Refresh Banlist from Server")) + { + _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; + } + + var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; + if (_bannedUsers.Count > 10) + tableFlags |= ImGuiTableFlags.ScrollY; + + if (ImGui.BeginTable("bannedusertable" + GroupFullInfo.GID, 6, tableFlags)) + { + ImGui.TableSetupColumn("UID", ImGuiTableColumnFlags.None, 1); + ImGui.TableSetupColumn("Alias", ImGuiTableColumnFlags.None, 1); + ImGui.TableSetupColumn("By", ImGuiTableColumnFlags.None, 1); + ImGui.TableSetupColumn("Date", ImGuiTableColumnFlags.None, 2); + ImGui.TableSetupColumn("Reason", ImGuiTableColumnFlags.None, 3); + ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.None, 1); + + ImGui.TableHeadersRow(); + + foreach (var bannedUser in _bannedUsers.ToList()) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(bannedUser.UID); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(bannedUser.UserAlias ?? string.Empty); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(bannedUser.BannedBy); + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture)); + + ImGui.TableNextColumn(); + UiSharedService.TextWrapped(bannedUser.Reason); + + ImGui.TableNextColumn(); + using var _ = ImRaii.PushId(bannedUser.UID); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) + { + _apiController.GroupUnbanUser(bannedUser); + _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); + } + } + + ImGui.EndTable(); + } + } + private void DrawInvites(GroupPermissions perm) { var inviteTab = ImRaii.TabItem("Invites"); @@ -476,6 +718,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private void DrawUserListCustom(IReadOnlyList pairs, GroupFullInfoDto GroupFullInfo) { + // Search bar (unchanged) ImGui.PushItemWidth(0); _uiSharedService.IconText(FontAwesomeIcon.Search, UIColors.Get("LightlessPurple")); ImGui.SameLine(); @@ -511,21 +754,20 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase if (p.Value.Value.IsPinned()) return 2; return 10; }) - .ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase); + .ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase) + .ToList(); + + ImGui.BeginChild("userListScroll#" + GroupFullInfo.Group.AliasOrGID, new Vector2(0, 0), true); var style = ImGui.GetStyle(); float fullW = ImGui.GetContentRegionAvail().X; + float colUid = fullW * 0.50f; float colFlags = fullW * 0.10f; - float colActions = fullW - colUid - colFlags - style.ItemSpacing.X * 2.5f; + float colActions = fullW - colUid - colFlags - style.ItemSpacing.X * 2.0f; DrawUserListHeader(colUid, colFlags); - bool useScroll = pairs.Count > 10; - float childHeight = useScroll ? 260f * ImGuiHelpers.GlobalScale : 0f; - - ImGui.BeginChild("userListScroll#" + GroupFullInfo.Group.AliasOrGID, new Vector2(0, childHeight), true); - int rowIndex = 0; foreach (var kv in orderedPairs) { @@ -533,8 +775,8 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var userInfoOpt = kv.Value; DrawUserRowCustom(pair, userInfoOpt, GroupFullInfo, rowIndex++, colUid, colFlags, colActions); } + ImGui.EndChild(); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); } private static void DrawUserListHeader(float colUid, float colFlags) @@ -544,18 +786,18 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessPurple")); - // Alias/UID/Note + // Alias / UID / Note ImGui.SetCursorPosX(x0); ImGui.TextUnformatted("Alias / UID / Note"); - // User Flags + // Flags ImGui.SameLine(); ImGui.SetCursorPosX(x0 + colUid + style.ItemSpacing.X); ImGui.TextUnformatted("Flags"); - // User Actions + // Actions ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.5f); + ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.0f); ImGui.TextUnformatted("Actions"); ImGui.PopStyleColor(); @@ -724,6 +966,27 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private void SavePruneSettings() + { + if (_autoPruneDays <= 0) + { + _autoPruneEnabled = false; + } + + var enabled = _autoPruneEnabled && _autoPruneDays > 0; + var dto = new GroupPruneSettingsDto(Group: GroupFullInfo.Group, AutoPruneEnabled: enabled, AutoPruneDays: enabled ? _autoPruneDays : 0); + + try + { + _apiController.GroupSetPruneSettings(dto).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save auto prune settings for group {GID}", GroupFullInfo.Group.GID); + UiSharedService.ColorTextWrapped("Failed to save auto-prune settings.", ImGuiColors.DalamudRed); + } + } + private static bool MatchesUserFilter(Pair pair, string filterLower) { var note = pair.GetNote() ?? string.Empty; diff --git a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs index d212f6c..88264b9 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.Functions.Groups.cs @@ -151,6 +151,20 @@ public partial class ApiController .ConfigureAwait(false); } + public async Task GroupGetPruneSettings(GroupDto dto) + { + CheckConnection(); + return await _lightlessHub!.InvokeAsync(nameof(GroupGetPruneSettings), dto) + .ConfigureAwait(false); + } + + public async Task GroupSetPruneSettings(GroupPruneSettingsDto dto) + { + CheckConnection(); + await _lightlessHub!.SendAsync(nameof(GroupSetPruneSettings), dto) + .ConfigureAwait(false); + } + private void CheckConnection() { if (ServerState is not (ServerState.Connected or ServerState.Connecting or ServerState.Reconnecting)) throw new InvalidDataException("Not connected"); diff --git a/LightlessSync/WebAPI/SignalR/HubFactory.cs b/LightlessSync/WebAPI/SignalR/HubFactory.cs index 1d5a0c8..9b008f0 100644 --- a/LightlessSync/WebAPI/SignalR/HubFactory.cs +++ b/LightlessSync/WebAPI/SignalR/HubFactory.cs @@ -71,6 +71,7 @@ public class HubFactory : MediatorSubscriberBase }; Logger.LogDebug("Building new HubConnection using transport {transport}", transportType); + var msgpackOptions = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.Lz4Block).WithResolver(ContractlessStandardResolver.Instance); _instance = new HubConnectionBuilder() .WithUrl(_serverConfigurationManager.CurrentApiUrl + ILightlessHub.Path, options => @@ -80,22 +81,7 @@ public class HubFactory : MediatorSubscriberBase }) .AddMessagePackProtocol(opt => { - var resolver = CompositeResolver.Create(StandardResolverAllowPrivate.Instance, - BuiltinResolver.Instance, - AttributeFormatterResolver.Instance, - // replace enum resolver - DynamicEnumAsStringResolver.Instance, - DynamicGenericResolver.Instance, - DynamicUnionResolver.Instance, - DynamicObjectResolver.Instance, - PrimitiveObjectResolver.Instance, - // final fallback(last priority) - StandardResolver.Instance); - - opt.SerializerOptions = - MessagePackSerializerOptions.Standard - .WithCompression(MessagePackCompression.Lz4Block) - .WithResolver(resolver); + opt.SerializerOptions = msgpackOptions; }) .WithAutomaticReconnect(new ForeverRetryPolicy(Mediator)) .ConfigureLogging(a => -- 2.49.1 From 2e14fc2f8f5a944aee86b9fc9018d00b1520c05d Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 9 Dec 2025 07:30:52 +0100 Subject: [PATCH 082/140] Cleaned up services context --- LightlessSync/Plugin.cs | 703 +++++++++++++++++++++++++--------------- 1 file changed, 437 insertions(+), 266 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 8f6031c..d1f3b6d 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -96,279 +96,450 @@ public sealed class Plugin : IDalamudPlugin }); lb.SetMinimumLevel(LogLevel.Trace); }) - .ConfigureServices(collection => + .ConfigureServices(services => + { + var configDir = pluginInterface.ConfigDirectory.FullName; + + // Core infrastructure + services.AddSingleton(new WindowSystem("LightlessSync")); + services.AddSingleton(); + services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); + services.AddSingleton(gameGui); + services.AddSingleton(addonLifecycle); + + // Core singletons + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => + new TextureMetadataHelper(sp.GetRequiredService>(), gameData)); + + services.AddSingleton(sp => new Lazy(() => sp.GetRequiredService())); + + services.AddSingleton(sp => new PairFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + new Lazy(() => sp.GetRequiredService()), + sp.GetRequiredService>())); + + services.AddSingleton(sp => new TransientResourceManager( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Lightless Chara data + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Game / VFX / IPC + services.AddSingleton(sp => new VfxSpawnManager( + sp.GetRequiredService>(), + gameInteropProvider, + sp.GetRequiredService())); + + services.AddSingleton(sp => new BlockedCharacterHandler( + sp.GetRequiredService>(), + gameInteropProvider)); + + services.AddSingleton(sp => new IpcProvider( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Tag (Groups) UIs + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Eventing / utilities + services.AddSingleton(sp => new EventAggregator( + configDir, + sp.GetRequiredService>(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new ActorObjectService( + sp.GetRequiredService>(), + framework, + gameInteropProvider, + objectTable, + clientState, + sp.GetRequiredService())); + + services.AddSingleton(sp => new DalamudUtilService( + sp.GetRequiredService>(), + clientState, + objectTable, + framework, + gameGui, + condition, + gameData, + targetManager, + gameConfig, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + new Lazy(() => sp.GetRequiredService()))); + + // Pairing and Dtr integration + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => new PairHandlerRegistry( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + + services.AddSingleton(sp => new DtrEntry( + sp.GetRequiredService>(), + dtrBar, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new PairCoordinator( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Light finder / redraw / context menu + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(sp => new LightFinderPlateHandler( + sp.GetRequiredService>(), + sp.GetRequiredService(), + pluginInterface, + sp.GetRequiredService(), + objectTable, + gameGui)); + + services.AddSingleton(sp => new LightFinderScannerService( + sp.GetRequiredService>(), + framework, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new ContextMenuService( + contextMenu, + pluginInterface, + gameData, + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + objectTable, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + clientState, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // IPC callers / manager + services.AddSingleton(sp => new IpcCallerPenumbra( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerGlamourer( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerCustomize( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerHeels( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerHonorific( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerMoodles( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerPetNames( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcCallerBrio( + sp.GetRequiredService>(), + pluginInterface, + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => new IpcManager( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Notifications / HTTP + services.AddSingleton(sp => new NotificationService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + notificationManager, + chatGui, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => { - collection.AddSingleton(new WindowSystem("LightlessSync")); - collection.AddSingleton(); - collection.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", "", useEmbedded: true)); - collection.AddSingleton(gameGui); + var httpClient = new HttpClient(); + var ver = Assembly.GetExecutingAssembly().GetName().Version; + httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("LightlessSync", $"{ver!.Major}.{ver.Minor}.{ver.Build}")); + return httpClient; + }); - // add lightless related singletons - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(s => - { - var logger = s.GetRequiredService>(); - return new TextureMetadataHelper(logger, gameData); - }); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(s => new PairFactory( - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - new Lazy(() => s.GetRequiredService()), - s.GetRequiredService>())); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(s => new Lazy(() => s.GetRequiredService())); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(s => new TransientResourceManager(s.GetRequiredService>(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); + // Lightless Config services + services.AddSingleton(sp => new UiThemeConfigService(configDir)); + services.AddSingleton(sp => new ChatConfigService(configDir)); + services.AddSingleton(sp => + { + var cfg = new LightlessConfigService(configDir); + var theme = sp.GetRequiredService(); + LightlessSync.UI.Style.MainStyle.Init(cfg, theme); + return cfg; + }); + services.AddSingleton(sp => new ServerConfigService(configDir)); + services.AddSingleton(sp => new NotesConfigService(configDir)); + services.AddSingleton(sp => new PairTagConfigService(configDir)); + services.AddSingleton(sp => new SyncshellTagConfigService(configDir)); + services.AddSingleton(sp => new TransientConfigService(configDir)); + services.AddSingleton(sp => new XivDataStorageService(configDir)); + services.AddSingleton(sp => new PlayerPerformanceConfigService(configDir)); + services.AddSingleton(sp => new CharaDataConfigService(configDir)); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); + // Config adapters + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); - collection.AddSingleton(s => new VfxSpawnManager(s.GetRequiredService>(), - gameInteropProvider, s.GetRequiredService())); - collection.AddSingleton((s) => new BlockedCharacterHandler(s.GetRequiredService>(), gameInteropProvider)); - collection.AddSingleton((s) => new IpcProvider(s.GetRequiredService>(), - pluginInterface, - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton((s) => new EventAggregator(pluginInterface.ConfigDirectory.FullName, - s.GetRequiredService>(), s.GetRequiredService())); - collection.AddSingleton((s) => new DalamudUtilService(s.GetRequiredService>(), - clientState, objectTable, framework, gameGui, condition, gameData, targetManager, gameConfig, - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), new Lazy(() => s.GetRequiredService()))); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(s => new PairHandlerRegistry( - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService>())); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton((s) => new DtrEntry( - s.GetRequiredService>(), - dtrBar, - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddSingleton(s => new PairCoordinator( - s.GetRequiredService>(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(addonLifecycle); - collection.AddSingleton(p => new ContextMenuService(contextMenu, pluginInterface, gameData, p.GetRequiredService>(), p.GetRequiredService(), p.GetRequiredService(), objectTable, - p.GetRequiredService(), - p.GetRequiredService(), - p.GetRequiredService(), - clientState, - p.GetRequiredService(), - p.GetRequiredService(), - p.GetRequiredService(), - p.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerPenumbra(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerGlamourer(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerCustomize(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerHeels(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerHonorific(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerMoodles(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerPetNames(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcCallerBrio(s.GetRequiredService>(), pluginInterface, - s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new IpcManager(s.GetRequiredService>(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new NotificationService( - s.GetRequiredService>(), - s.GetRequiredService(), - s.GetRequiredService(), - notificationManager, - chatGui, - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddSingleton((s) => - { - var httpClient = new HttpClient(); - var ver = Assembly.GetExecutingAssembly().GetName().Version; - httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("LightlessSync", ver!.Major + "." + ver!.Minor + "." + ver!.Build)); - return httpClient; - }); - collection.AddSingleton((s) => new UiThemeConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new ChatConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => - { - var cfg = new LightlessConfigService(pluginInterface.ConfigDirectory.FullName); - var theme = s.GetRequiredService(); - LightlessSync.UI.Style.MainStyle.Init(cfg, theme); - return cfg; - }); - collection.AddSingleton((s) => new ServerConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new NotesConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new PairTagConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new SyncshellTagConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new PlayerPerformanceConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton((s) => new CharaDataConfigService(pluginInterface.ConfigDirectory.FullName)); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton>(s => s.GetRequiredService()); - collection.AddSingleton(); - collection.AddSingleton(); - collection.AddSingleton(sp => new ActorObjectService( - sp.GetRequiredService>(), - framework, - gameInteropProvider, - objectTable, - clientState, - sp.GetRequiredService())); - collection.AddSingleton(); - collection.AddSingleton(s => new LightFinderScannerService(s.GetRequiredService>(), framework, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddSingleton((s) => new LightFinderPlateHandler(s.GetRequiredService>(), - s.GetRequiredService(), pluginInterface, - s.GetRequiredService(), - objectTable, gameGui)); + services.AddSingleton(); + services.AddSingleton(); - // add scoped services - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); + // Scoped factories / UI + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - collection.AddScoped((s) => new EditProfileUi(s.GetRequiredService>(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped(); - collection.AddScoped((s) => new LightFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped((s) => new SyncshellFinderUI(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped(); - collection.AddScoped((s) => - new LightlessNotificationUi( - s.GetRequiredService>(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped(); - collection.AddScoped((s) => new UiService(s.GetRequiredService>(), pluginInterface.UiBuilder, s.GetRequiredService(), - s.GetRequiredService(), s.GetServices(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService(), - s.GetRequiredService())); - collection.AddScoped((s) => new CommandManagerService(commandManager, s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService())); - collection.AddScoped((s) => new UiSharedService(s.GetRequiredService>(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - pluginInterface, textureProvider, s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), - s.GetRequiredService())); - collection.AddScoped((s) => new NameplateService(s.GetRequiredService>(), s.GetRequiredService(), clientState, gameGui, objectTable, gameInteropProvider, - s.GetRequiredService(), s.GetRequiredService())); + services.AddScoped(sp => new EditProfileUi( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - collection.AddHostedService(p => p.GetRequiredService()); - }) - .Build(); + services.AddScoped(); + + services.AddScoped(sp => new LightFinderUI( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(sp => new SyncshellFinderUI( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => + new LightlessNotificationUi( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(sp => new UiService( + sp.GetRequiredService>(), + pluginInterface.UiBuilder, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetServices(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(sp => new CommandManagerService( + commandManager, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(sp => new UiSharedService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + pluginInterface, + textureProvider, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddScoped(sp => new NameplateService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + clientState, + gameGui, + objectTable, + gameInteropProvider, + sp.GetRequiredService(), + sp.GetRequiredService())); + + // Hosted services + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + }).Build(); _ = _host.StartAsync(); } -- 2.49.1 From 6cf0e3daed38d57ec84c875213225c18fd8b474a Mon Sep 17 00:00:00 2001 From: azyges Date: Thu, 11 Dec 2025 12:59:32 +0900 Subject: [PATCH 083/140] various 'improvements' --- .gitmodules | 3 + LightlessSync.sln | 14 + .../FileCache/TransientResourceManager.cs | 2 +- LightlessSync/LightlessSync.csproj | 1 + .../Factories/FileDownloadManagerFactory.cs | 5 - .../PlayerData/Handlers/GameObjectHandler.cs | 7 +- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 27 + .../Pairs/IPairHandlerAdapterFactory.cs | 6 + .../PlayerData/Pairs/PairHandlerAdapter.cs | 473 +++++---- .../Pairs/PairHandlerAdapterFactory.cs | 93 ++ LightlessSync/Plugin.cs | 16 +- .../ActorTracking/ActorObjectService.cs | 494 ++++++--- .../Services/Chat/ZoneChatService.cs | 76 +- LightlessSync/Services/DalamudUtilService.cs | 4 +- .../LightFinder/LightFinderPlateHandler.cs | 849 +++++++++++---- .../Rendering/PctDrawListExtensions.cs | 82 ++ .../Services/Rendering/PictomancyService.cs | 47 + .../TextureDownscaleService.cs | 87 +- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 203 ++-- LightlessSync/UI/ZoneChatUi.cs | 55 +- LightlessSync/Utils/SeStringUtils.cs | 63 +- LightlessSync/Utils/VariousExtensions.cs | 3 +- .../WebAPI/Files/FileDownloadManager.cs | 47 +- LightlessSync/packages.lock.json | 962 +++++++++++++++++ Pictomancy/Pictomancy/packages.lock.json | 970 ++++++++++++++++++ ffxiv_pictomancy | 1 + 26 files changed, 3706 insertions(+), 884 deletions(-) create mode 100644 LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs create mode 100644 LightlessSync/PlayerData/Pairs/IPairHandlerAdapterFactory.cs create mode 100644 LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs create mode 100644 LightlessSync/Services/Rendering/PctDrawListExtensions.cs create mode 100644 LightlessSync/Services/Rendering/PictomancyService.cs create mode 100644 Pictomancy/Pictomancy/packages.lock.json create mode 160000 ffxiv_pictomancy diff --git a/.gitmodules b/.gitmodules index 7879cd2..7ae9eb6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,3 +13,6 @@ [submodule "OtterGui"] path = OtterGui url = https://github.com/Ottermandias/OtterGui +[submodule "ffxiv_pictomancy"] + path = ffxiv_pictomancy + url = https://github.com/sourpuh/ffxiv_pictomancy diff --git a/LightlessSync.sln b/LightlessSync.sln index 8d92b53..55bddfd 100644 --- a/LightlessSync.sln +++ b/LightlessSync.sln @@ -20,6 +20,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.GameData", "Penumb EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterGui.csproj", "{C77A2833-3FE4-405B-811D-439B1FF859D9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -102,6 +104,18 @@ Global {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x64.Build.0 = Release|x64 {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.ActiveCfg = Release|x64 {C77A2833-3FE4-405B-811D-439B1FF859D9}.Release|x86.Build.0 = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.ActiveCfg = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|Any CPU.Build.0 = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.ActiveCfg = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x64.Build.0 = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.ActiveCfg = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Debug|x86.Build.0 = Debug|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.ActiveCfg = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|Any CPU.Build.0 = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.ActiveCfg = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64 + {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 7f982a3..6d77a97 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -426,7 +426,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase () => { if (!string.IsNullOrEmpty(descriptor.HashedContentId) && - _actorObjectService.TryGetActorByHash(descriptor.HashedContentId, out var current) && + _actorObjectService.TryGetValidatedActorByHash(descriptor.HashedContentId, out var current) && current.OwnedKind == kind) { return current.Address; diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 975e935..8930cb6 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -79,6 +79,7 @@ + diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index f9b522a..e3697cf 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -1,7 +1,6 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; -using LightlessSync.Services.PairProcessing; using LightlessSync.Services.TextureCompression; using LightlessSync.WebAPI.Files; using Microsoft.Extensions.Logging; @@ -15,7 +14,6 @@ public class FileDownloadManagerFactory private readonly FileTransferOrchestrator _fileTransferOrchestrator; private readonly FileCacheManager _fileCacheManager; private readonly FileCompactor _fileCompactor; - private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly LightlessConfigService _configService; private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureMetadataHelper _textureMetadataHelper; @@ -26,7 +24,6 @@ public class FileDownloadManagerFactory FileTransferOrchestrator fileTransferOrchestrator, FileCacheManager fileCacheManager, FileCompactor fileCompactor, - PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService, TextureDownscaleService textureDownscaleService, TextureMetadataHelper textureMetadataHelper) @@ -36,7 +33,6 @@ public class FileDownloadManagerFactory _fileTransferOrchestrator = fileTransferOrchestrator; _fileCacheManager = fileCacheManager; _fileCompactor = fileCompactor; - _pairProcessingLimiter = pairProcessingLimiter; _configService = configService; _textureDownscaleService = textureDownscaleService; _textureMetadataHelper = textureMetadataHelper; @@ -50,7 +46,6 @@ public class FileDownloadManagerFactory _fileTransferOrchestrator, _fileCacheManager, _fileCompactor, - _pairProcessingLimiter, _configService, _textureDownscaleService, _textureMetadataHelper); diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 8d56b4f..829c737 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -94,6 +94,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP public DrawCondition CurrentDrawCondition { get; set; } = DrawCondition.None; public byte Gender { get; private set; } public string Name { get; private set; } + public uint EntityId { get; private set; } = uint.MaxValue; public ObjectKind ObjectKind { get; } public byte RaceId { get; private set; } public byte TribeId { get; private set; } @@ -142,6 +143,7 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP { Address = IntPtr.Zero; DrawObjectAddress = IntPtr.Zero; + EntityId = uint.MaxValue; _haltProcessing = false; } @@ -171,13 +173,16 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP Address = _getAddress(); if (Address != IntPtr.Zero) { - var drawObjAddr = (IntPtr)((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->DrawObject; + var gameObject = (FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address; + var drawObjAddr = (IntPtr)gameObject->DrawObject; DrawObjectAddress = drawObjAddr; + EntityId = gameObject->EntityId; CurrentDrawCondition = DrawCondition.None; } else { DrawObjectAddress = IntPtr.Zero; + EntityId = uint.MaxValue; CurrentDrawCondition = DrawCondition.DrawObjectZero; } diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs new file mode 100644 index 0000000..c89d311 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -0,0 +1,27 @@ +using LightlessSync.API.Data; + +namespace LightlessSync.PlayerData.Pairs; + +/// +/// orchestrates the lifecycle of a paired character +/// +public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject +{ + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + + void Initialize(); + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); +} diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapterFactory.cs new file mode 100644 index 0000000..167b5bc --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapterFactory.cs @@ -0,0 +1,6 @@ +namespace LightlessSync.PlayerData.Pairs; + +public interface IPairHandlerAdapterFactory +{ + IPairHandlerAdapter Create(string ident); +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 70f4f0b..925c42c 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -16,43 +16,17 @@ using LightlessSync.Services.TextureCompression; using LightlessSync.Utils; using LightlessSync.WebAPI.Files; using LightlessSync.WebAPI.Files.Models; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; +using FileReplacementDataComparer = LightlessSync.PlayerData.Data.FileReplacementDataComparer; namespace LightlessSync.PlayerData.Pairs; /// -/// orchestrates the lifecycle of a paired character +/// handles lifecycle, visibility, queued data, character data for a paired user /// -public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject -{ - string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - - void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); -} - -public interface IPairHandlerAdapterFactory -{ - IPairHandlerAdapter Create(string ident); -} - -internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter, IPairPerformanceSubject +internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPairHandlerAdapter { private sealed record CombatData(Guid ApplicationId, CharacterData CharacterData, bool Forced); @@ -70,14 +44,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly PairStateCache _pairStateCache; private readonly PairPerformanceMetricsCache _performanceMetricsCache; private readonly PairManager _pairManager; - private CancellationTokenSource? _applicationCancellationTokenSource = new(); + private CancellationTokenSource? _applicationCancellationTokenSource; private Guid _applicationId; private Task? _applicationTask; private CharacterData? _cachedData = null; private GameObjectHandler? _charaHandler; private readonly Dictionary _customizeIds = []; private CombatData? _dataReceivedInDowntime; - private CancellationTokenSource? _downloadCancellationTokenSource = new(); + private CancellationTokenSource? _downloadCancellationTokenSource; private bool _forceApplyMods = false; private bool _forceFullReapply; private Dictionary<(string GamePath, string? Hash), string>? _lastAppliedModdedPaths; @@ -86,6 +60,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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; @@ -183,7 +158,6 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return; } - var user = GetPrimaryUserData(); if (LastAppliedDataBytes < 0 || LastAppliedDataTris < 0 || LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { @@ -441,9 +415,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return combined; } public nint PlayerCharacter => _charaHandler?.Address ?? nint.Zero; - public unsafe uint PlayerCharacterId => (_charaHandler?.Address ?? nint.Zero) == nint.Zero - ? uint.MaxValue - : ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)_charaHandler!.Address)->EntityId; + public uint PlayerCharacterId => _charaHandler?.EntityId ?? uint.MaxValue; public string? PlayerName { get; private set; } public string PlayerNameHash => Ident; @@ -490,14 +462,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (shouldForce) { _forceApplyMods = true; - _cachedData = null; + _forceFullReapply = true; LastAppliedDataBytes = -1; LastAppliedDataTris = -1; LastAppliedApproximateVRAMBytes = -1; LastAppliedApproximateEffectiveVRAMBytes = -1; } - var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + var sanitized = CloneAndSanitizeLastReceived(out _); if (sanitized is null) { Logger.LogTrace("Sanitized data null for {Ident}", Ident); @@ -746,7 +718,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (characterData is null) { Logger.LogWarning("[BASE-{appBase}] Received null character data, skipping application for {handler}", applicationBase, GetLogIdentifier()); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -757,7 +729,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: you are in combat, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -767,7 +739,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: you are performing music, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -777,7 +749,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: you are in an instance, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -787,7 +759,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: you are in a cutscene, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -797,7 +769,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: you are in GPose, deferring application"))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -807,7 +779,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Cannot apply character data: Penumbra or Glamourer is not available, deferring application"))); Logger.LogInformation("[BASE-{appbase}] Application of data for {player} while Penumbra/Glamourer unavailable, returning", applicationBase, GetLogIdentifier()); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); - SetUploading(isUploading: false); + SetUploading(false); return; } @@ -828,7 +800,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); } - SetUploading(isUploading: false); + SetUploading(false); Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); Logger.LogDebug("[BASE-{appbase}] Hash for data is {newHash}, current cache hash is {oldHash}", applicationBase, characterData.DataHash.Value, _cachedData?.DataHash.Value ?? "NODATA"); @@ -850,10 +822,13 @@ 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)) @@ -863,7 +838,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appbase}] Downloading and applying character for {name}", applicationBase, GetPrimaryAliasOrUidSafe()); - var forceFullReapply = _forceFullReapply || forceApplyCustomization + var forceFullReapply = _forceFullReapply || LastAppliedApproximateVRAMBytes < 0 || LastAppliedDataTris < 0; DownloadAndApplyCharacter(applicationBase, characterData.DeepClone(), charaDataToUpdate, forceFullReapply); @@ -875,12 +850,12 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return $"{alias}:{PlayerName ?? string.Empty}:{(PlayerCharacter != nint.Zero ? "HasChar" : "NoChar")}"; } - public void SetUploading(bool isUploading = true) + public void SetUploading(bool uploading) { - Logger.LogTrace("Setting {name} uploading {uploading}", GetPrimaryAliasOrUidSafe(), isUploading); + Logger.LogTrace("Setting {name} uploading {uploading}", GetPrimaryAliasOrUidSafe(), uploading); if (_charaHandler != null) { - Mediator.Publish(new PlayerUploadingMessage(_charaHandler, isUploading)); + Mediator.Publish(new PlayerUploadingMessage(_charaHandler, uploading)); } } @@ -904,7 +879,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { base.Dispose(disposing); - SetUploading(isUploading: false); + SetUploading(false); var name = PlayerName; var user = GetPrimaryUserDataSafe(); var alias = GetPrimaryAliasOrUidSafe(); @@ -1046,6 +1021,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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); break; @@ -1061,6 +1041,45 @@ 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) { var result = new Dictionary>(); @@ -1126,6 +1145,39 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return result; } + private static bool PlayerModFilesChanged(CharacterData newData, CharacterData? previousData) + { + return !FileReplacementListsEqual( + TryGetFileReplacementList(newData, ObjectKind.Player), + TryGetFileReplacementList(previousData, ObjectKind.Player)); + } + + private static IReadOnlyCollection? TryGetFileReplacementList(CharacterData? data, ObjectKind objectKind) + { + if (data is null) + { + return null; + } + + return data.FileReplacements.TryGetValue(objectKind, out var list) ? list : null; + } + + private static bool FileReplacementListsEqual(IReadOnlyCollection? left, IReadOnlyCollection? right) + { + if (left is null || left.Count == 0) + { + return right is null || right.Count == 0; + } + + if (right is null || right.Count == 0) + { + return false; + } + + var comparer = FileReplacementDataComparer.Instance; + return !left.Except(right, comparer).Any() && !right.Except(left, comparer).Any(); + } + private void DownloadAndApplyCharacter(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool forceFullReapply) { if (!updatedData.Any()) @@ -1165,7 +1217,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadCancellationTokenSource = _downloadCancellationTokenSource?.CancelRecreate() ?? new CancellationTokenSource(); var downloadToken = _downloadCancellationTokenSource.Token; - _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken).ConfigureAwait(false); + _ = DownloadAndApplyCharacterAsync(applicationBase, charaData, updatedData, updateModdedPaths, updateManip, cachedModdedPaths, downloadToken) + .ConfigureAwait(false); } private Task? _pairDownloadTask; @@ -1173,107 +1226,114 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) { - await using var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); - bool skipDownscaleForPair = ShouldSkipDownscale(); - var user = GetPrimaryUserData(); - Dictionary<(string GamePath, string? Hash), string> moddedPaths; - - if (updateModdedPaths) + var concurrencyLease = await _pairProcessingLimiter.AcquireAsync(downloadToken).ConfigureAwait(false); + try { - if (cachedModdedPaths is not null) + bool skipDownscaleForPair = ShouldSkipDownscale(); + var user = GetPrimaryUserData(); + Dictionary<(string GamePath, string? Hash), string> moddedPaths; + + if (updateModdedPaths) { - moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer); + if (cachedModdedPaths is not null) + { + moddedPaths = new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer); + } + else + { + int attempts = 0; + List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) + { + if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) + { + Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); + await _pairDownloadTask.ConfigureAwait(false); + } + + Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); + + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, + $"Starting download for {toDownloadReplacements.Count} files"))); + var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); + + if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) + { + _downloadManager.ClearDownload(); + return; + } + + var handlerForDownload = _charaHandler; + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); + + await _pairDownloadTask.ConfigureAwait(false); + + if (downloadToken.IsCancellationRequested) + { + Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + return; + } + + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); + + if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); + } + + if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) + { + return; + } + } } else { - int attempts = 0; - List toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - while (toDownloadReplacements.Count > 0 && attempts++ <= 10 && !downloadToken.IsCancellationRequested) - { - if (_pairDownloadTask != null && !_pairDownloadTask.IsCompleted) - { - Logger.LogDebug("[BASE-{appBase}] Finishing prior running download task for player {name}, {kind}", applicationBase, PlayerName, updatedData); - await _pairDownloadTask.ConfigureAwait(false); - } - - Logger.LogDebug("[BASE-{appBase}] Downloading missing files for player {name}, {kind}", applicationBase, PlayerName, updatedData); - - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), EventSeverity.Informational, - $"Starting download for {toDownloadReplacements.Count} files"))); - var toDownloadFiles = await _downloadManager.InitiateDownloadList(_charaHandler!, toDownloadReplacements, downloadToken).ConfigureAwait(false); - - if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) - { - _downloadManager.ClearDownload(); - return; - } - - var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair).ConfigureAwait(false)); - - await _pairDownloadTask.ConfigureAwait(false); - - if (downloadToken.IsCancellationRequested) - { - Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); - return; - } - - toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); - - if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) - { - break; - } - - await Task.Delay(TimeSpan.FromSeconds(2), downloadToken).ConfigureAwait(false); - } - - if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) - { - return; - } + moddedPaths = cachedModdedPaths is not null + ? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer) + : []; } + + downloadToken.ThrowIfCancellationRequested(); + + var handlerForApply = _charaHandler; + if (handlerForApply is null || handlerForApply.Address == nint.Zero) + { + Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + return; + } + + var appToken = _applicationCancellationTokenSource?.Token; + while ((!_applicationTask?.IsCompleted ?? false) + && !downloadToken.IsCancellationRequested + && (!appToken?.IsCancellationRequested ?? false)) + { + Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); + await Task.Delay(250).ConfigureAwait(false); + } + + if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) + { + _forceFullReapply = true; + return; + } + + _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); + var token = _applicationCancellationTokenSource.Token; + + _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } - else + finally { - moddedPaths = cachedModdedPaths is not null - ? new Dictionary<(string GamePath, string? Hash), string>(cachedModdedPaths, cachedModdedPaths.Comparer) - : []; + await concurrencyLease.DisposeAsync().ConfigureAwait(false); } - - downloadToken.ThrowIfCancellationRequested(); - - var handlerForApply = _charaHandler; - if (handlerForApply is null || handlerForApply.Address == nint.Zero) - { - Logger.LogDebug("[BASE-{appBase}] Handler not available for {player}, cached data for later application", applicationBase, GetLogIdentifier()); - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - return; - } - - var appToken = _applicationCancellationTokenSource?.Token; - while ((!_applicationTask?.IsCompleted ?? false) - && !downloadToken.IsCancellationRequested - && (!appToken?.IsCancellationRequested ?? false)) - { - Logger.LogDebug("[BASE-{appBase}] Waiting for current data application (Id: {id}) for player ({handler}) to finish", applicationBase, _applicationId, PlayerName); - await Task.Delay(250).ConfigureAwait(false); - } - - if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) - { - _forceFullReapply = true; - return; - } - - _applicationCancellationTokenSource = _applicationCancellationTokenSource.CancelRecreate() ?? new CancellationTokenSource(); - var token = _applicationCancellationTokenSource.Token; - - _applicationTask = ApplyCharacterDataAsync(applicationBase, handlerForApply, charaData, updatedData, updateModdedPaths, updateManip, moddedPaths, token); } private async Task ApplyCharacterDataAsync(Guid applicationBase, GameObjectHandler handlerForApply, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, @@ -1416,6 +1476,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { + _forceFullReapply = true; ApplyCharacterData(appData, cachedData!, forceApplyCustomization: true); } catch (Exception ex) @@ -1432,6 +1493,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { try { + _forceFullReapply = true; ApplyLastReceivedData(forced: true); } catch (Exception ex) @@ -1468,21 +1530,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _serverConfigManager.AutoPopulateNoteForUid(user.UID, name); } - Mediator.Subscribe(this, async (_) => + Mediator.Subscribe(this, _message => { - if (string.IsNullOrEmpty(_cachedData?.HonorificData)) return; - Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier()); - await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, _cachedData.HonorificData).ConfigureAwait(false); + var honorificData = _cachedData?.HonorificData; + if (string.IsNullOrEmpty(honorificData)) + return; + + _ = ReapplyHonorificAsync(honorificData!); }); - Mediator.Subscribe(this, async (_) => + Mediator.Subscribe(this, _message => { - if (string.IsNullOrEmpty(_cachedData?.PetNamesData)) return; - Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier()); - await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, _cachedData.PetNamesData).ConfigureAwait(false); + var petNamesData = _cachedData?.PetNamesData; + if (string.IsNullOrEmpty(petNamesData)) + return; + + _ = ReapplyPetNamesAsync(petNamesData!); }); } + private async Task ReapplyHonorificAsync(string honorificData) + { + Logger.LogTrace("Reapplying Honorific data for {handler}", GetLogIdentifier()); + await _ipcManager.Honorific.SetTitleAsync(PlayerCharacter, honorificData).ConfigureAwait(false); + } + + private async Task ReapplyPetNamesAsync(string petNamesData) + { + Logger.LogTrace("Reapplying Pet Names data for {handler}", GetLogIdentifier()); + await _ipcManager.PetNames.SetPlayerData(PlayerCharacter, petNamesData).ConfigureAwait(false); + } + private async Task RevertCustomizationDataAsync(ObjectKind objectKind, string name, Guid applicationId, CancellationToken cancelToken) { nint address = _dalamudUtil.GetPlayerCharacterFromCachedTableByIdent(Ident); @@ -1572,14 +1650,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { token.ThrowIfCancellationRequested(); var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash); - if (fileCache != null) + if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath)) { - if (!File.Exists(fileCache.ResolvedFilepath)) - { - Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash); - _fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); - fileCache = null; - } + Logger.LogTrace("[BASE-{appBase}] Cached path {Path} missing on disk for hash {Hash}, removing cache entry", applicationBase, fileCache.ResolvedFilepath, item.Hash); + _fileDbManager.RemoveHashedFile(fileCache.Hash, fileCache.PrefixedFilePath); + fileCache = null; } if (fileCache != null) @@ -1701,7 +1776,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (penumbraCollection != Guid.Empty) { await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, character.ObjectIndex).ConfigureAwait(false); - await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary()).ConfigureAwait(false); + await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, applicationId, penumbraCollection, new Dictionary(StringComparer.Ordinal)).ConfigureAwait(false); await _ipcManager.Penumbra.SetManipulationDataAsync(Logger, applicationId, penumbraCollection, string.Empty).ConfigureAwait(false); } } @@ -1775,83 +1850,3 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } - -internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory -{ - private readonly ILoggerFactory _loggerFactory; - private readonly LightlessMediator _mediator; - private readonly PairManager _pairManager; - private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; - private readonly IpcManager _ipcManager; - private readonly FileDownloadManagerFactory _fileDownloadManagerFactory; - private readonly PluginWarningNotificationService _pluginWarningNotificationManager; - private readonly IServiceProvider _serviceProvider; - private readonly IHostApplicationLifetime _lifetime; - private readonly FileCacheManager _fileCacheManager; - private readonly PlayerPerformanceService _playerPerformanceService; - private readonly PairProcessingLimiter _pairProcessingLimiter; - private readonly ServerConfigurationManager _serverConfigManager; - private readonly TextureDownscaleService _textureDownscaleService; - private readonly PairStateCache _pairStateCache; - private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; - - public PairHandlerAdapterFactory( - ILoggerFactory loggerFactory, - LightlessMediator mediator, - PairManager pairManager, - GameObjectHandlerFactory gameObjectHandlerFactory, - IpcManager ipcManager, - FileDownloadManagerFactory fileDownloadManagerFactory, - PluginWarningNotificationService pluginWarningNotificationManager, - IServiceProvider serviceProvider, - IHostApplicationLifetime lifetime, - FileCacheManager fileCacheManager, - PlayerPerformanceService playerPerformanceService, - PairProcessingLimiter pairProcessingLimiter, - ServerConfigurationManager serverConfigManager, - TextureDownscaleService textureDownscaleService, - PairStateCache pairStateCache, - PairPerformanceMetricsCache pairPerformanceMetricsCache) - { - _loggerFactory = loggerFactory; - _mediator = mediator; - _pairManager = pairManager; - _gameObjectHandlerFactory = gameObjectHandlerFactory; - _ipcManager = ipcManager; - _fileDownloadManagerFactory = fileDownloadManagerFactory; - _pluginWarningNotificationManager = pluginWarningNotificationManager; - _serviceProvider = serviceProvider; - _lifetime = lifetime; - _fileCacheManager = fileCacheManager; - _playerPerformanceService = playerPerformanceService; - _pairProcessingLimiter = pairProcessingLimiter; - _serverConfigManager = serverConfigManager; - _textureDownscaleService = textureDownscaleService; - _pairStateCache = pairStateCache; - _pairPerformanceMetricsCache = pairPerformanceMetricsCache; - } - - public IPairHandlerAdapter Create(string ident) - { - var downloadManager = _fileDownloadManagerFactory.Create(); - var dalamudUtilService = _serviceProvider.GetRequiredService(); - return new PairHandlerAdapter( - _loggerFactory.CreateLogger(), - _mediator, - _pairManager, - ident, - _gameObjectHandlerFactory, - _ipcManager, - downloadManager, - _pluginWarningNotificationManager, - dalamudUtilService, - _lifetime, - _fileCacheManager, - _playerPerformanceService, - _pairProcessingLimiter, - _serverConfigManager, - _textureDownscaleService, - _pairStateCache, - _pairPerformanceMetricsCache); - } -} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs new file mode 100644 index 0000000..1fe2703 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapterFactory.cs @@ -0,0 +1,93 @@ +using LightlessSync.FileCache; +using LightlessSync.Interop.Ipc; +using LightlessSync.PlayerData.Factories; +using LightlessSync.Services; +using LightlessSync.Services.Mediator; +using LightlessSync.Services.PairProcessing; +using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Services.TextureCompression; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LightlessSync.PlayerData.Pairs; + +internal sealed class PairHandlerAdapterFactory : IPairHandlerAdapterFactory +{ + private readonly ILoggerFactory _loggerFactory; + private readonly LightlessMediator _mediator; + private readonly PairManager _pairManager; + private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; + private readonly IpcManager _ipcManager; + private readonly FileDownloadManagerFactory _fileDownloadManagerFactory; + private readonly PluginWarningNotificationService _pluginWarningNotificationManager; + private readonly IServiceProvider _serviceProvider; + private readonly IHostApplicationLifetime _lifetime; + private readonly FileCacheManager _fileCacheManager; + private readonly PlayerPerformanceService _playerPerformanceService; + private readonly PairProcessingLimiter _pairProcessingLimiter; + private readonly ServerConfigurationManager _serverConfigManager; + private readonly TextureDownscaleService _textureDownscaleService; + private readonly PairStateCache _pairStateCache; + private readonly PairPerformanceMetricsCache _pairPerformanceMetricsCache; + + public PairHandlerAdapterFactory( + ILoggerFactory loggerFactory, + LightlessMediator mediator, + PairManager pairManager, + GameObjectHandlerFactory gameObjectHandlerFactory, + IpcManager ipcManager, + FileDownloadManagerFactory fileDownloadManagerFactory, + PluginWarningNotificationService pluginWarningNotificationManager, + IServiceProvider serviceProvider, + IHostApplicationLifetime lifetime, + FileCacheManager fileCacheManager, + PlayerPerformanceService playerPerformanceService, + PairProcessingLimiter pairProcessingLimiter, + ServerConfigurationManager serverConfigManager, + TextureDownscaleService textureDownscaleService, + PairStateCache pairStateCache, + PairPerformanceMetricsCache pairPerformanceMetricsCache) + { + _loggerFactory = loggerFactory; + _mediator = mediator; + _pairManager = pairManager; + _gameObjectHandlerFactory = gameObjectHandlerFactory; + _ipcManager = ipcManager; + _fileDownloadManagerFactory = fileDownloadManagerFactory; + _pluginWarningNotificationManager = pluginWarningNotificationManager; + _serviceProvider = serviceProvider; + _lifetime = lifetime; + _fileCacheManager = fileCacheManager; + _playerPerformanceService = playerPerformanceService; + _pairProcessingLimiter = pairProcessingLimiter; + _serverConfigManager = serverConfigManager; + _textureDownscaleService = textureDownscaleService; + _pairStateCache = pairStateCache; + _pairPerformanceMetricsCache = pairPerformanceMetricsCache; + } + + public IPairHandlerAdapter Create(string ident) + { + var downloadManager = _fileDownloadManagerFactory.Create(); + var dalamudUtilService = _serviceProvider.GetRequiredService(); + return new PairHandlerAdapter( + _loggerFactory.CreateLogger(), + _mediator, + _pairManager, + ident, + _gameObjectHandlerFactory, + _ipcManager, + downloadManager, + _pluginWarningNotificationManager, + dalamudUtilService, + _lifetime, + _fileCacheManager, + _playerPerformanceService, + _pairProcessingLimiter, + _serverConfigManager, + _textureDownscaleService, + _pairStateCache, + _pairPerformanceMetricsCache); + } +} diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index d1f3b6d..2cf8bdf 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -18,6 +18,7 @@ using LightlessSync.Services.ActorTracking; using LightlessSync.Services.CharaData; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; +using LightlessSync.Services.Rendering; using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.TextureCompression; using LightlessSync.UI; @@ -33,8 +34,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NReco.Logging.File; -using System; -using System.IO; using System.Net.Http.Headers; using System.Reflection; using OtterTex; @@ -178,6 +177,10 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService())); + services.AddSingleton(sp => new PictomancyService( + sp.GetRequiredService>(), + pluginInterface)); + // Tag (Groups) UIs services.AddSingleton(); services.AddSingleton(); @@ -260,11 +263,14 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(sp => new LightFinderPlateHandler( sp.GetRequiredService>(), - sp.GetRequiredService(), - pluginInterface, + addonLifecycle, + gameGui, sp.GetRequiredService(), + sp.GetRequiredService(), objectTable, - gameGui)); + sp.GetRequiredService(), + pluginInterface, + sp.GetRequiredService())); services.AddSingleton(sp => new LightFinderScannerService( sp.GetRequiredService>(), diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index 2305c2a..c2650ad 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -1,27 +1,20 @@ -using LightlessSync; -using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using Dalamud.Game; -using Dalamud.Game.ClientState; +using System.Collections.Concurrent; using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Hooking; using Dalamud.Plugin.Services; +using FFXIVClientStructs.Interop; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; using LightlessSync.Services.Mediator; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; -using FFXIVClientStructs.Interop; -using System.Threading; +using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.Services.ActorTracking; -public sealed unsafe class ActorObjectService : IHostedService, IDisposable +public sealed class ActorObjectService : IHostedService, IDisposable { public readonly record struct ActorDescriptor( string Name, @@ -38,25 +31,13 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable private readonly IFramework _framework; private readonly IGameInteropProvider _interop; private readonly IObjectTable _objectTable; - private readonly IClientState _clientState; private readonly LightlessMediator _mediator; private readonly ConcurrentDictionary _activePlayers = new(); private readonly ConcurrentDictionary _actorsByHash = new(StringComparer.Ordinal); private readonly ConcurrentDictionary> _actorsByName = new(StringComparer.Ordinal); - private ActorDescriptor[] _playerCharacterSnapshot = Array.Empty(); - private nint[] _playerAddressSnapshot = Array.Empty(); - private readonly HashSet _renderedPlayers = new(); - private readonly HashSet _renderedCompanions = new(); - private readonly Dictionary _ownedObjects = new(); - private nint[] _renderedPlayerSnapshot = Array.Empty(); - private nint[] _renderedCompanionSnapshot = Array.Empty(); - private nint[] _ownedObjectSnapshot = Array.Empty(); - private IReadOnlyDictionary _ownedObjectMapSnapshot = new Dictionary(); - private nint _localPlayerAddress = nint.Zero; - private nint _localPetAddress = nint.Zero; - private nint _localMinionMountAddress = nint.Zero; - private nint _localCompanionAddress = nint.Zero; + private readonly OwnedObjectTracker _ownedTracker = new(); + private ActorSnapshot _snapshot = ActorSnapshot.Empty; private Hook? _onInitializeHook; private Hook? _onTerminateHook; @@ -80,16 +61,30 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable _framework = framework; _interop = interop; _objectTable = objectTable; - _clientState = clientState; _mediator = mediator; } - public IReadOnlyList PlayerAddresses => Volatile.Read(ref _playerAddressSnapshot); + private ActorSnapshot Snapshot => Volatile.Read(ref _snapshot); + + public IReadOnlyList PlayerAddresses => Snapshot.PlayerAddresses; public IEnumerable PlayerDescriptors => _activePlayers.Values; - public IReadOnlyList PlayerCharacterDescriptors => Volatile.Read(ref _playerCharacterSnapshot); + public IReadOnlyList PlayerCharacterDescriptors => Snapshot.PlayerDescriptors; public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor); + public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor) + { + descriptor = default; + if (!_actorsByHash.TryGetValue(hash, out var candidate)) + return false; + + if (!ValidateDescriptorThreadSafe(candidate)) + return false; + + descriptor = candidate; + return true; + } + public bool TryGetPlayerByName(string name, out ActorDescriptor descriptor) { descriptor = default; @@ -100,6 +95,9 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable ActorDescriptor? best = null; foreach (var candidate in entries.Values) { + if (!ValidateDescriptorThreadSafe(candidate)) + continue; + if (best is null || IsBetterNameMatch(candidate, best.Value)) { best = candidate; @@ -115,23 +113,54 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return false; } public bool HooksActive => _hooksActive; - public IReadOnlyList RenderedPlayerAddresses => Volatile.Read(ref _renderedPlayerSnapshot); - public IReadOnlyList RenderedCompanionAddresses => Volatile.Read(ref _renderedCompanionSnapshot); - public IReadOnlyList OwnedObjectAddresses => Volatile.Read(ref _ownedObjectSnapshot); - public IReadOnlyDictionary OwnedObjects => Volatile.Read(ref _ownedObjectMapSnapshot); - public nint LocalPlayerAddress => Volatile.Read(ref _localPlayerAddress); - public nint LocalPetAddress => Volatile.Read(ref _localPetAddress); - public nint LocalMinionOrMountAddress => Volatile.Read(ref _localMinionMountAddress); - public nint LocalCompanionAddress => Volatile.Read(ref _localCompanionAddress); + public IReadOnlyList RenderedPlayerAddresses => Snapshot.OwnedObjects.RenderedPlayers; + public IReadOnlyList RenderedCompanionAddresses => Snapshot.OwnedObjects.RenderedCompanions; + public IReadOnlyList OwnedObjectAddresses => Snapshot.OwnedObjects.OwnedAddresses; + public IReadOnlyDictionary OwnedObjects => Snapshot.OwnedObjects.Map; + public nint LocalPlayerAddress => Snapshot.OwnedObjects.LocalPlayer; + public nint LocalPetAddress => Snapshot.OwnedObjects.LocalPet; + public nint LocalMinionOrMountAddress => Snapshot.OwnedObjects.LocalMinionOrMount; + public nint LocalCompanionAddress => Snapshot.OwnedObjects.LocalCompanion; + + public bool TryGetOwnedKind(nint address, out LightlessObjectKind kind) + => OwnedObjects.TryGetValue(address, out kind); + + public bool TryGetOwnedActor(LightlessObjectKind kind, out ActorDescriptor descriptor) + { + descriptor = default; + if (!TryGetOwnedObject(kind, out var address)) + return false; + return TryGetDescriptor(address, out descriptor); + } + + public bool TryGetOwnedObjectByIndex(ushort objectIndex, out LightlessObjectKind ownedKind) + { + ownedKind = default; + var ownedSnapshot = OwnedObjects; + foreach (var (address, kind) in ownedSnapshot) + { + if (!TryGetDescriptor(address, out var descriptor)) + continue; + + if (descriptor.ObjectIndex == objectIndex) + { + ownedKind = kind; + return true; + } + } + + return false; + } public bool TryGetOwnedObject(LightlessObjectKind kind, out nint address) { + var ownedSnapshot = Snapshot.OwnedObjects; address = kind switch { - LightlessObjectKind.Player => Volatile.Read(ref _localPlayerAddress), - LightlessObjectKind.Pet => Volatile.Read(ref _localPetAddress), - LightlessObjectKind.MinionOrMount => Volatile.Read(ref _localMinionMountAddress), - LightlessObjectKind.Companion => Volatile.Read(ref _localCompanionAddress), + LightlessObjectKind.Player => ownedSnapshot.LocalPlayer, + LightlessObjectKind.Pet => ownedSnapshot.LocalPet, + LightlessObjectKind.MinionOrMount => ownedSnapshot.LocalMinionOrMount, + LightlessObjectKind.Companion => ownedSnapshot.LocalCompanion, _ => nint.Zero }; @@ -158,7 +187,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable public bool TryGetPlayerAddressByHash(string hash, out nint address) { - if (TryGetActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero) + if (TryGetValidatedActorByHash(hash, out var descriptor) && descriptor.Address != nint.Zero) { address = descriptor.Address; return true; @@ -168,6 +197,50 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return false; } + public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default) + { + if (address == nint.Zero) + throw new ArgumentException("Address cannot be zero.", nameof(address)); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false); + if (isLoaded) + return; + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + } + + private bool ValidateDescriptorThreadSafe(ActorDescriptor descriptor) + { + if (_framework.IsInFrameworkUpdateThread) + return ValidateDescriptorInternal(descriptor); + + return _framework.RunOnFrameworkThread(() => ValidateDescriptorInternal(descriptor)).GetAwaiter().GetResult(); + } + + private bool ValidateDescriptorInternal(ActorDescriptor descriptor) + { + if (descriptor.Address == nint.Zero) + return false; + + if (descriptor.ObjectKind == DalamudObjectKind.Player && + !string.IsNullOrEmpty(descriptor.HashedContentId)) + { + var liveHash = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address); + if (!string.Equals(liveHash, descriptor.HashedContentId, StringComparison.Ordinal)) + { + UntrackGameObject(descriptor.Address); + return false; + } + } + + return true; + } + public void RefreshTrackedActors(bool force = false) { var now = DateTime.UtcNow; @@ -185,7 +258,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable } else { - _framework.RunOnFrameworkThread(RefreshTrackedActorsInternal); + _ = _framework.RunOnFrameworkThread(RefreshTrackedActorsInternal); } } @@ -211,23 +284,12 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable _activePlayers.Clear(); _actorsByHash.Clear(); _actorsByName.Clear(); - Volatile.Write(ref _playerCharacterSnapshot, Array.Empty()); - Volatile.Write(ref _playerAddressSnapshot, Array.Empty()); - Volatile.Write(ref _renderedPlayerSnapshot, Array.Empty()); - Volatile.Write(ref _renderedCompanionSnapshot, Array.Empty()); - Volatile.Write(ref _ownedObjectSnapshot, Array.Empty()); - Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary()); - Volatile.Write(ref _localPlayerAddress, nint.Zero); - Volatile.Write(ref _localPetAddress, nint.Zero); - Volatile.Write(ref _localMinionMountAddress, nint.Zero); - Volatile.Write(ref _localCompanionAddress, nint.Zero); - _renderedPlayers.Clear(); - _renderedCompanions.Clear(); - _ownedObjects.Clear(); + _ownedTracker.Reset(); + Volatile.Write(ref _snapshot, ActorSnapshot.Empty); return Task.CompletedTask; } - private void InitializeHooks() + private unsafe void InitializeHooks() { if (_hooksActive) return; @@ -271,7 +333,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable }); } - private void OnCharacterInitialized(Character* chara) + private unsafe void OnCharacterInitialized(Character* chara) { try { @@ -285,7 +347,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable QueueFrameworkUpdate(() => TrackGameObject((GameObject*)chara)); } - private void OnCharacterTerminated(Character* chara) + private unsafe void OnCharacterTerminated(Character* chara) { var address = (nint)chara; QueueFrameworkUpdate(() => UntrackGameObject(address)); @@ -299,7 +361,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable } } - private GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) + private unsafe GameObject* OnCharacterDisposed(Character* chara, byte freeMemory) { var address = (nint)chara; QueueFrameworkUpdate(() => UntrackGameObject(address)); @@ -314,7 +376,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable } } - private void TrackGameObject(GameObject* gameObject) + private unsafe void TrackGameObject(GameObject* gameObject) { if (gameObject == null) return; @@ -332,14 +394,10 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable if (_activePlayers.TryGetValue(descriptor.Address, out var existing)) { - RemoveDescriptorFromIndexes(existing); - RemoveDescriptorFromCollections(existing); + RemoveDescriptor(existing); } - _activePlayers[descriptor.Address] = descriptor; - IndexDescriptor(descriptor); - AddDescriptorToCollections(descriptor); - RebuildSnapshots(); + AddDescriptor(descriptor); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -355,16 +413,16 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable _mediator.Publish(new ActorTrackedMessage(descriptor)); } - private ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind) + private unsafe ActorDescriptor? BuildDescriptor(GameObject* gameObject, DalamudObjectKind objectKind) { if (gameObject == null) return null; var address = (nint)gameObject; string name = string.Empty; - ushort objectIndex = (ushort)gameObject->ObjectIndex; + ushort objectIndex = gameObject->ObjectIndex; bool isInGpose = objectIndex >= 200; - bool isLocal = _clientState.LocalPlayer?.Address == address; + bool isLocal = _objectTable.LocalPlayer?.Address == address; string hashedCid = string.Empty; if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter) @@ -372,7 +430,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable name = playerCharacter.Name.TextValue ?? string.Empty; objectIndex = playerCharacter.ObjectIndex; isInGpose = objectIndex >= 200; - isLocal = playerCharacter.Address == _clientState.LocalPlayer?.Address; + isLocal = playerCharacter.Address == _objectTable.LocalPlayer?.Address; } else { @@ -389,7 +447,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return new ActorDescriptor(name, hashedCid, address, objectIndex, isLocal, isInGpose, objectKind, ownedKind, ownerEntityId); } - private (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer) + private unsafe (LightlessObjectKind? OwnedKind, uint OwnerEntityId) DetermineOwnedKind(GameObject* gameObject, DalamudObjectKind objectKind, bool isLocalPlayer) { if (gameObject == null) return (null, 0); @@ -406,7 +464,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return (LightlessObjectKind.Player, entityId); } - if (_clientState.LocalPlayer is not { } localPlayer) + if (_objectTable.LocalPlayer is not { } localPlayer) return (null, 0); var ownerId = gameObject->OwnerId; @@ -453,9 +511,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable if (_activePlayers.TryRemove(address, out var descriptor)) { - RemoveDescriptorFromIndexes(descriptor); - RemoveDescriptorFromCollections(descriptor); - RebuildSnapshots(); + RemoveDescriptor(descriptor); if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Actor untracked: {Name} addr={Address:X} idx={Index} owned={OwnedKind}", @@ -469,7 +525,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable } } - private void RefreshTrackedActorsInternal() + private unsafe void RefreshTrackedActorsInternal() { var addresses = EnumerateActiveCharacterAddresses(); HashSet seen = new(addresses.Count); @@ -524,7 +580,10 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return candidate.ObjectIndex < current.ObjectIndex; } - private void OnCompanionInitialized(Companion* companion) + private bool TryGetDescriptor(nint address, out ActorDescriptor descriptor) + => _activePlayers.TryGetValue(address, out descriptor); + + private unsafe void OnCompanionInitialized(Companion* companion) { try { @@ -538,7 +597,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable QueueFrameworkUpdate(() => TrackGameObject((GameObject*)companion)); } - private void OnCompanionTerminated(Companion* companion) + private unsafe void OnCompanionTerminated(Companion* companion) { var address = (nint)companion; QueueFrameworkUpdate(() => UntrackGameObject(address)); @@ -559,107 +618,46 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable _actorsByHash.TryRemove(descriptor.HashedContentId, out _); } - if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.Name)) + if (descriptor.ObjectKind == DalamudObjectKind.Player + && !string.IsNullOrEmpty(descriptor.Name) + && _actorsByName.TryGetValue(descriptor.Name, out var bucket)) { - if (_actorsByName.TryGetValue(descriptor.Name, out var bucket)) + bucket.TryRemove(descriptor.Address, out _); + if (bucket.IsEmpty) { - bucket.TryRemove(descriptor.Address, out _); - if (bucket.IsEmpty) - { - _actorsByName.TryRemove(descriptor.Name, out _); - } + _actorsByName.TryRemove(descriptor.Name, out _); } } } - private void AddDescriptorToCollections(ActorDescriptor descriptor) + private void AddDescriptor(ActorDescriptor descriptor) { - if (descriptor.ObjectKind == DalamudObjectKind.Player) - { - _renderedPlayers.Add(descriptor.Address); - if (descriptor.IsLocalPlayer) - { - Volatile.Write(ref _localPlayerAddress, descriptor.Address); - } - } - else if (descriptor.ObjectKind == DalamudObjectKind.Companion) - { - _renderedCompanions.Add(descriptor.Address); - } - - if (descriptor.OwnedKind is { } ownedKind) - { - _ownedObjects[descriptor.Address] = ownedKind; - switch (ownedKind) - { - case LightlessObjectKind.Player: - Volatile.Write(ref _localPlayerAddress, descriptor.Address); - break; - case LightlessObjectKind.Pet: - Volatile.Write(ref _localPetAddress, descriptor.Address); - break; - case LightlessObjectKind.MinionOrMount: - Volatile.Write(ref _localMinionMountAddress, descriptor.Address); - break; - case LightlessObjectKind.Companion: - Volatile.Write(ref _localCompanionAddress, descriptor.Address); - break; - } - } + _activePlayers[descriptor.Address] = descriptor; + IndexDescriptor(descriptor); + _ownedTracker.OnDescriptorAdded(descriptor); + PublishSnapshot(); } - private void RemoveDescriptorFromCollections(ActorDescriptor descriptor) + private void RemoveDescriptor(ActorDescriptor descriptor) { - if (descriptor.ObjectKind == DalamudObjectKind.Player) - { - _renderedPlayers.Remove(descriptor.Address); - if (descriptor.IsLocalPlayer && Volatile.Read(ref _localPlayerAddress) == descriptor.Address) - { - Volatile.Write(ref _localPlayerAddress, nint.Zero); - } - } - else if (descriptor.ObjectKind == DalamudObjectKind.Companion) - { - _renderedCompanions.Remove(descriptor.Address); - if (Volatile.Read(ref _localCompanionAddress) == descriptor.Address) - { - Volatile.Write(ref _localCompanionAddress, nint.Zero); - } - } - - if (descriptor.OwnedKind is { } ownedKind) - { - _ownedObjects.Remove(descriptor.Address); - switch (ownedKind) - { - case LightlessObjectKind.Player when Volatile.Read(ref _localPlayerAddress) == descriptor.Address: - Volatile.Write(ref _localPlayerAddress, nint.Zero); - break; - case LightlessObjectKind.Pet when Volatile.Read(ref _localPetAddress) == descriptor.Address: - Volatile.Write(ref _localPetAddress, nint.Zero); - break; - case LightlessObjectKind.MinionOrMount when Volatile.Read(ref _localMinionMountAddress) == descriptor.Address: - Volatile.Write(ref _localMinionMountAddress, nint.Zero); - break; - case LightlessObjectKind.Companion when Volatile.Read(ref _localCompanionAddress) == descriptor.Address: - Volatile.Write(ref _localCompanionAddress, nint.Zero); - break; - } - } + RemoveDescriptorFromIndexes(descriptor); + _ownedTracker.OnDescriptorRemoved(descriptor); + PublishSnapshot(); } - private void RebuildSnapshots() + private void PublishSnapshot() { var playerDescriptors = _activePlayers.Values .Where(descriptor => descriptor.ObjectKind == DalamudObjectKind.Player) .ToArray(); + var playerAddresses = new nint[playerDescriptors.Length]; + for (var i = 0; i < playerDescriptors.Length; i++) + playerAddresses[i] = playerDescriptors[i].Address; - Volatile.Write(ref _playerCharacterSnapshot, playerDescriptors); - Volatile.Write(ref _playerAddressSnapshot, playerDescriptors.Select(d => d.Address).ToArray()); - Volatile.Write(ref _renderedPlayerSnapshot, _renderedPlayers.ToArray()); - Volatile.Write(ref _renderedCompanionSnapshot, _renderedCompanions.ToArray()); - Volatile.Write(ref _ownedObjectSnapshot, _ownedObjects.Keys.ToArray()); - Volatile.Write(ref _ownedObjectMapSnapshot, new Dictionary(_ownedObjects)); + var ownedSnapshot = _ownedTracker.CreateSnapshot(); + var nextGeneration = Snapshot.Generation + 1; + var snapshot = new ActorSnapshot(playerDescriptors, playerAddresses, ownedSnapshot, nextGeneration); + Volatile.Write(ref _snapshot, snapshot); } private void QueueFrameworkUpdate(Action action) @@ -673,7 +671,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return; } - _framework.RunOnFrameworkThread(action); + _ = _framework.RunOnFrameworkThread(action); } private void DisposeHooks() @@ -723,7 +721,7 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable or DalamudObjectKind.Companion or DalamudObjectKind.MountType; - private static List EnumerateActiveCharacterAddresses() + private static unsafe List EnumerateActiveCharacterAddresses() { var results = new List(64); var manager = GameObjectManager.Instance(); @@ -751,4 +749,170 @@ public sealed unsafe class ActorObjectService : IHostedService, IDisposable return results; } + + private static unsafe bool IsObjectFullyLoaded(nint address) + { + if (address == nint.Zero) + return false; + + var gameObject = (GameObject*)address; + if (gameObject == null) + return false; + + var drawObject = gameObject->DrawObject; + if (drawObject == null) + return false; + + if (gameObject->RenderFlags == 2048) + return false; + + var characterBase = (CharacterBase*)drawObject; + if (characterBase == null) + return false; + + if (characterBase->HasModelInSlotLoaded != 0) + return false; + + if (characterBase->HasModelFilesInSlotLoaded != 0) + return false; + + return true; + } + + private sealed class OwnedObjectTracker + { + private readonly HashSet _renderedPlayers = new(); + private readonly HashSet _renderedCompanions = new(); + private readonly Dictionary _ownedObjects = new(); + private nint _localPlayerAddress = nint.Zero; + private nint _localPetAddress = nint.Zero; + private nint _localMinionMountAddress = nint.Zero; + private nint _localCompanionAddress = nint.Zero; + + public void OnDescriptorAdded(ActorDescriptor descriptor) + { + if (descriptor.ObjectKind == DalamudObjectKind.Player) + { + _renderedPlayers.Add(descriptor.Address); + if (descriptor.IsLocalPlayer) + _localPlayerAddress = descriptor.Address; + } + else if (descriptor.ObjectKind == DalamudObjectKind.Companion) + { + _renderedCompanions.Add(descriptor.Address); + } + + if (descriptor.OwnedKind is { } ownedKind) + { + _ownedObjects[descriptor.Address] = ownedKind; + switch (ownedKind) + { + case LightlessObjectKind.Player: + _localPlayerAddress = descriptor.Address; + break; + case LightlessObjectKind.Pet: + _localPetAddress = descriptor.Address; + break; + case LightlessObjectKind.MinionOrMount: + _localMinionMountAddress = descriptor.Address; + break; + case LightlessObjectKind.Companion: + _localCompanionAddress = descriptor.Address; + break; + } + } + } + + public void OnDescriptorRemoved(ActorDescriptor descriptor) + { + if (descriptor.ObjectKind == DalamudObjectKind.Player) + { + _renderedPlayers.Remove(descriptor.Address); + if (descriptor.IsLocalPlayer && _localPlayerAddress == descriptor.Address) + _localPlayerAddress = nint.Zero; + } + else if (descriptor.ObjectKind == DalamudObjectKind.Companion) + { + _renderedCompanions.Remove(descriptor.Address); + if (_localCompanionAddress == descriptor.Address) + _localCompanionAddress = nint.Zero; + } + + if (descriptor.OwnedKind is { } ownedKind) + { + _ownedObjects.Remove(descriptor.Address); + switch (ownedKind) + { + case LightlessObjectKind.Player when _localPlayerAddress == descriptor.Address: + _localPlayerAddress = nint.Zero; + break; + case LightlessObjectKind.Pet when _localPetAddress == descriptor.Address: + _localPetAddress = nint.Zero; + break; + case LightlessObjectKind.MinionOrMount when _localMinionMountAddress == descriptor.Address: + _localMinionMountAddress = nint.Zero; + break; + case LightlessObjectKind.Companion when _localCompanionAddress == descriptor.Address: + _localCompanionAddress = nint.Zero; + break; + } + } + } + + public OwnedObjectSnapshot CreateSnapshot() + => new( + _renderedPlayers.ToArray(), + _renderedCompanions.ToArray(), + _ownedObjects.Keys.ToArray(), + new Dictionary(_ownedObjects), + _localPlayerAddress, + _localPetAddress, + _localMinionMountAddress, + _localCompanionAddress); + + public void Reset() + { + _renderedPlayers.Clear(); + _renderedCompanions.Clear(); + _ownedObjects.Clear(); + _localPlayerAddress = nint.Zero; + _localPetAddress = nint.Zero; + _localMinionMountAddress = nint.Zero; + _localCompanionAddress = nint.Zero; + } + } + + private sealed record OwnedObjectSnapshot( + IReadOnlyList RenderedPlayers, + IReadOnlyList RenderedCompanions, + IReadOnlyList OwnedAddresses, + IReadOnlyDictionary Map, + nint LocalPlayer, + nint LocalPet, + nint LocalMinionOrMount, + nint LocalCompanion) + { + public static OwnedObjectSnapshot Empty { get; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + new Dictionary(), + nint.Zero, + nint.Zero, + nint.Zero, + nint.Zero); + } + + private sealed record ActorSnapshot( + IReadOnlyList PlayerDescriptors, + IReadOnlyList PlayerAddresses, + OwnedObjectSnapshot OwnedObjects, + int Generation) + { + public static ActorSnapshot Empty { get; } = new( + Array.Empty(), + Array.Empty(), + OwnedObjectSnapshot.Empty, + 0); + } } diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 9126436..edb3a86 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -1,4 +1,3 @@ -using LightlessSync.API.Dto; using LightlessSync.API.Dto.Chat; using LightlessSync.Services.ActorTracking; using LightlessSync.Services.Mediator; @@ -21,12 +20,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private const int MaxReportContextLength = 1000; private readonly ApiController _apiController; - private readonly ChatConfigService _chatConfigService; private readonly DalamudUtilService _dalamudUtilService; private readonly ActorObjectService _actorObjectService; private readonly PairUiService _pairUiService; - private readonly object _sync = new(); + private readonly Lock _sync = new(); private readonly Dictionary _channels = new(StringComparer.Ordinal); private readonly List _channelOrder = new(); @@ -55,7 +53,6 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS PairUiService pairUiService) : base(logger, mediator) { - _chatConfigService = chatConfigService; _apiController = apiController; _dalamudUtilService = dalamudUtilService; _actorObjectService = actorObjectService; @@ -63,12 +60,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS _isLoggedIn = _dalamudUtilService.IsLoggedIn; _isConnected = _apiController.IsConnected; - _chatEnabled = _chatConfigService.Current.AutoEnableChatOnLogin; + _chatEnabled = chatConfigService.Current.AutoEnableChatOnLogin; } public IReadOnlyList GetChannelsSnapshot() { - lock (_sync) + using (_sync.EnterScope()) { var snapshots = new List(_channelOrder.Count); foreach (var key in _channelOrder) @@ -107,7 +104,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { get { - lock (_sync) + using (_sync.EnterScope()) { return _chatEnabled; } @@ -118,7 +115,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { get { - lock (_sync) + using (_sync.EnterScope()) { return _chatEnabled && _isConnected; } @@ -127,7 +124,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS public void SetActiveChannel(string? key) { - lock (_sync) + using (_sync.EnterScope()) { _activeChannelKey = key; if (key is not null && _channels.TryGetValue(key, out var state)) @@ -145,7 +142,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private async Task EnableChatAsync() { bool wasEnabled; - lock (_sync) + using (_sync.EnterScope()) { wasEnabled = _chatEnabled; if (!wasEnabled) @@ -170,7 +167,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS List groupDescriptors; ChatChannelDescriptor? zoneDescriptor; - lock (_sync) + using (_sync.EnterScope()) { wasEnabled = _chatEnabled; if (!wasEnabled) @@ -259,7 +256,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return Task.FromResult(new ChatReportResult(false, "Please describe why you are reporting this message.")); } - lock (_sync) + using (_sync.EnterScope()) { if (!_chatEnabled) { @@ -311,8 +308,8 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS Mediator.Subscribe(this, _ => HandleLogout()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate(force: true)); - Mediator.Subscribe(this, msg => HandleConnected(msg.Connection)); - Mediator.Subscribe(this, _ => HandleConnected(null)); + Mediator.Subscribe(this, _ => HandleConnected()); + Mediator.Subscribe(this, _ => HandleConnected()); Mediator.Subscribe(this, _ => HandleReconnecting()); Mediator.Subscribe(this, _ => HandleReconnecting()); Mediator.Subscribe(this, _ => RefreshGroupsFromPairManager()); @@ -371,11 +368,11 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } } - private void HandleConnected(ConnectionDto? connection) + private void HandleConnected() { _isConnected = true; - lock (_sync) + using (_sync.EnterScope()) { _selfTokens.Clear(); _pendingSelfMessages.Clear(); @@ -410,7 +407,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { _isConnected = false; - lock (_sync) + using (_sync.EnterScope()) { _selfTokens.Clear(); _pendingSelfMessages.Clear(); @@ -475,7 +472,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private void UpdateChannelsForDisabledState() { - lock (_sync) + using (_sync.EnterScope()) { foreach (var state in _channels.Values) { @@ -513,7 +510,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS string? zoneKey; ZoneChannelDefinition? definition = null; - lock (_sync) + using (_sync.EnterScope()) { _territoryToZoneKey.TryGetValue(territoryId, out zoneKey); if (zoneKey is not null) @@ -538,7 +535,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS bool shouldForceSend; - lock (_sync) + using (_sync.EnterScope()) { var state = EnsureZoneStateLocked(); state.DisplayName = definition.Value.DisplayName; @@ -566,7 +563,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS ChatChannelDescriptor? descriptor = null; bool clearedHistory = false; - lock (_sync) + using (_sync.EnterScope()) { descriptor = _lastZoneDescriptor; _lastZoneDescriptor = null; @@ -590,9 +587,9 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.DisplayName = "Zone Chat"; } - if (_activeChannelKey == ZoneChannelKey) + if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) { - _activeChannelKey = _channelOrder.FirstOrDefault(key => key != ZoneChannelKey); + _activeChannelKey = _channelOrder.FirstOrDefault(key => !string.Equals(key, ZoneChannelKey, StringComparison.Ordinal)); } } @@ -627,7 +624,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { var infoList = infos ?? Array.Empty(); - lock (_sync) + using (_sync.EnterScope()) { _zoneDefinitions.Clear(); _territoryToZoneKey.Clear(); @@ -657,7 +654,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { if (def.TerritoryNames.Contains(variant)) { - _territoryToZoneKey[(uint)kvp.Key] = def.Key; + _territoryToZoneKey[kvp.Key] = def.Key; break; } } @@ -689,7 +686,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS var descriptorsToJoin = new List(); var descriptorsToLeave = new List(); - lock (_sync) + using (_sync.EnterScope()) { var remainingGroups = new HashSet(_groupDefinitions.Keys, StringComparer.OrdinalIgnoreCase); @@ -807,7 +804,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return; List descriptors; - lock (_sync) + using (_sync.EnterScope()) { descriptors = _channels.Values .Where(state => state.Type == ChatChannelType.Group) @@ -832,7 +829,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS var presenceKey = BuildPresenceKey(descriptor); bool stateMatches; - lock (_sync) + using (_sync.EnterScope()) { stateMatches = !force && _lastPresenceStates.TryGetValue(presenceKey, out var lastState) @@ -846,7 +843,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { await _apiController.UpdateChatPresence(new ChatPresenceUpdateDto(descriptor, territoryId, isActive)).ConfigureAwait(false); - lock (_sync) + using (_sync.EnterScope()) { if (isActive) { @@ -870,7 +867,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS var key = normalized.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(normalized); var pending = new PendingSelfMessage(key, message); - lock (_sync) + using (_sync.EnterScope()) { _pendingSelfMessages.Add(pending); while (_pendingSelfMessages.Count > 20) @@ -884,7 +881,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private void RemovePendingSelfMessage(PendingSelfMessage pending) { - lock (_sync) + using (_sync.EnterScope()) { var index = _pendingSelfMessages.FindIndex(p => string.Equals(p.ChannelKey, pending.ChannelKey, StringComparison.Ordinal) && @@ -905,7 +902,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS var message = BuildMessage(dto, fromSelf); bool publishChannelList = false; - lock (_sync) + using (_sync.EnterScope()) { if (!_channels.TryGetValue(key, out var state)) { @@ -960,7 +957,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS if (publishChannelList) { - lock (_sync) + using (_sync.EnterScope()) { UpdateChannelOrderLocked(); } @@ -973,7 +970,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { if (dto.Sender.User?.UID is { } uid && string.Equals(uid, _apiController.UID, StringComparison.Ordinal)) { - lock (_sync) + using (_sync.EnterScope()) { _selfTokens[channelKey] = dto.Sender.Token; } @@ -981,7 +978,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return true; } - lock (_sync) + using (_sync.EnterScope()) { if (_selfTokens.TryGetValue(channelKey, out var token) && string.Equals(token, dto.Sender.Token, StringComparison.Ordinal)) @@ -1014,7 +1011,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { var isZone = dto.Channel.Type == ChatChannelType.Zone; if (!string.IsNullOrEmpty(dto.Sender.HashedCid) && - _actorObjectService.TryGetActorByHash(dto.Sender.HashedCid, out var descriptor) && + _actorObjectService.TryGetValidatedActorByHash(dto.Sender.HashedCid, out var descriptor) && !string.IsNullOrWhiteSpace(descriptor.Name)) { return descriptor.Name; @@ -1065,7 +1062,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { _activeChannelKey = _channelOrder[0]; } - else if (_activeChannelKey is not null && !_channelOrder.Contains(_activeChannelKey)) + else if (_activeChannelKey is not null && !_channelOrder.Contains(_activeChannelKey, StringComparer.Ordinal)) { _activeChannelKey = _channelOrder.Count > 0 ? _channelOrder[0] : null; } @@ -1108,7 +1105,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private static bool ChannelDescriptorsMatch(ChatChannelDescriptor left, ChatChannelDescriptor right) => left.Type == right.Type - && NormalizeKey(left.CustomKey) == NormalizeKey(right.CustomKey) + && string.Equals(NormalizeKey(left.CustomKey), NormalizeKey(right.CustomKey), StringComparison.Ordinal) && left.WorldId == right.WorldId; private ChatChannelState EnsureZoneStateLocked() @@ -1180,6 +1177,3 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private readonly record struct PendingSelfMessage(string ChannelKey, string Message); } - - - diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 1bbef90..fdf2ec3 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -360,7 +360,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public IntPtr GetPlayerCharacterFromCachedTableByIdent(string characterName) { - if (_actorObjectService.TryGetActorByHash(characterName, out var actor)) + if (_actorObjectService.TryGetValidatedActorByHash(characterName, out var actor)) return actor.Address; return IntPtr.Zero; } @@ -639,7 +639,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber internal (string Name, nint Address) FindPlayerByNameHash(string ident) { - if (_actorObjectService.TryGetActorByHash(ident, out var descriptor)) + if (_actorObjectService.TryGetValidatedActorByHash(ident, out var descriptor)) { return (descriptor.Name, descriptor.Address); } diff --git a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs index 8002beb..544ada1 100644 --- a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs +++ b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs @@ -1,249 +1,692 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Game.ClientState.Objects.Types; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Addon.Lifecycle; +using Dalamud.Game.Addon.Lifecycle.AddonArgTypes; +using Dalamud.Game.ClientState.Objects.Enums; +using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Plugin; using Dalamud.Plugin.Services; -using FFXIVClientStructs.FFXIV.Client.Game.Object; +using FFXIVClientStructs.FFXIV.Client.System.Framework; +using FFXIVClientStructs.FFXIV.Client.UI; +using FFXIVClientStructs.FFXIV.Component.GUI; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; +using LightlessSync.Services.Rendering; using LightlessSync.UI; -using Microsoft.Extensions.Hosting; +using LightlessSync.UI.Services; +using LightlessSync.Utils; +using LightlessSync.UtilsEnum.Enum; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Pictomancy; using System.Collections.Immutable; +using System.Globalization; using System.Numerics; +using Task = System.Threading.Tasks.Task; -namespace LightlessSync.Services.LightFinder +namespace LightlessSync.Services.LightFinder; + +public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber { - public class LightFinderPlateHandler : IHostedService, IMediatorSubscriber + private readonly ILogger _logger; + private readonly IAddonLifecycle _addonLifecycle; + private readonly IGameGui _gameGui; + private readonly IObjectTable _objectTable; + private readonly LightlessConfigService _configService; + private readonly PairUiService _pairUiService; + private readonly LightlessMediator _mediator; + public LightlessMediator Mediator => _mediator; + + private readonly IUiBuilder _uiBuilder; + private bool _mEnabled; + private bool _needsLabelRefresh; + private bool _drawSubscribed; + private AddonNamePlate* _mpNameplateAddon; + private readonly object _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 ImmutableHashSet _activeBroadcastingCids = []; + + public LightFinderPlateHandler( + ILogger logger, + IAddonLifecycle addonLifecycle, + IGameGui gameGui, + LightlessConfigService configService, + LightlessMediator mediator, + IObjectTable objectTable, + PairUiService pairUiService, + IDalamudPluginInterface pluginInterface, + PictomancyService pictomancyService) { - private readonly ILogger _logger; - private readonly LightlessConfigService _configService; - private readonly IDalamudPluginInterface _pluginInterface; - private readonly IObjectTable _gameObjects; - private readonly IGameGui _gameGui; + _logger = logger; + _addonLifecycle = addonLifecycle; + _gameGui = gameGui; + _configService = configService; + _mediator = mediator; + _objectTable = objectTable; + _pairUiService = pairUiService; + _uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface)); + _ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService)); - private const float _defaultNameplateDistance = 15.0f; - private ImmutableHashSet _activeBroadcastingCids = []; - private readonly Dictionary _smoothed = []; - private readonly float _defaultHeightOffset = 0f; + } - public LightlessMediator Mediator { get; } - - public LightFinderPlateHandler( - ILogger logger, - LightlessMediator mediator, - IDalamudPluginInterface dalamudPluginInterface, - LightlessConfigService configService, - IObjectTable gameObjects, - IGameGui gameGui) + internal void Init() + { + if (!_drawSubscribed) { - _logger = logger; - Mediator = mediator; - _pluginInterface = dalamudPluginInterface; - _configService = configService; - _gameObjects = gameObjects; - _gameGui = gameGui; + _uiBuilder.Draw += OnUiBuilderDraw; + _drawSubscribed = true; } - public Task StartAsync(CancellationToken cancellationToken) + EnableNameplate(); + _mediator.Subscribe(this, OnTick); + } + + internal void Uninit() + { + DisableNameplate(); + if (_drawSubscribed) { - _logger.LogInformation("Starting LightFinderPlateHandler..."); - - _pluginInterface.UiBuilder.Draw += OnDraw; - - _logger.LogInformation("LightFinderPlateHandler started."); - return Task.CompletedTask; + _uiBuilder.Draw -= OnUiBuilderDraw; + _drawSubscribed = false; } + ClearLabelBuffer(); + _mediator.Unsubscribe(this); + _mpNameplateAddon = null; + } - public Task StopAsync(CancellationToken cancellationToken) + internal void EnableNameplate() + { + if (!_mEnabled) { - _logger.LogInformation("Stopping LightFinderPlateHandler..."); - - _pluginInterface.UiBuilder.Draw -= OnDraw; - - _logger.LogInformation("LightFinderPlateHandler stopped."); - return Task.CompletedTask; - } - - private unsafe void OnDraw() - { - if (!_configService.Current.BroadcastEnabled) - return; - - if (_activeBroadcastingCids.Count == 0) - return; - - var drawList = ImGui.GetForegroundDrawList(); - - foreach (var obj in _gameObjects.PlayerObjects.OfType()) + try { - //Double check to be sure, should always be true due to OfType filter above - if (obj is not IPlayerCharacter player) - continue; - - if (player.Address == IntPtr.Zero) - continue; - - var hashedCID = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address); - if (!_activeBroadcastingCids.Contains(hashedCID)) - continue; - - //Approximate check if nameplate should be visible (at short distances) - if (!ShouldApproximateNameplateVisible(player)) - continue; - - if (!TryGetApproxNameplateScreenPos(player, out var rawScreenPos)) - continue; - - var rawVector3 = new Vector3(rawScreenPos.X, rawScreenPos.Y, 0f); - - if (rawVector3 == Vector3.Zero) - { - _smoothed.Remove(obj); - continue; - } - - //Possible have to rework this. Currently just a simple distance check to avoid jitter. - Vector3 smoothedVector3; - - if (_smoothed.TryGetValue(obj, out var lastVector3)) - { - var deltaVector2 = new Vector2(rawVector3.X - lastVector3.X, rawVector3.Y - lastVector3.Y); - if (deltaVector2.Length() < 1f) - smoothedVector3 = lastVector3; - else - smoothedVector3 = rawVector3; - } - else - { - smoothedVector3 = rawVector3; - } - - _smoothed[obj] = smoothedVector3; - - var screenPos = new Vector2(smoothedVector3.X, smoothedVector3.Y); - - var radiusWorld = Math.Max(player.HitboxRadius, 0.5f); - var radiusPx = radiusWorld * 8.0f; - var offsetPx = GetScreenOffset(player); - var drawPos = new Vector2(screenPos.X, screenPos.Y - offsetPx); - - var fillColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("Lightfinder"))); - var outlineColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("LightfinderEdge"))); - - drawList.AddCircleFilled(drawPos, radiusPx, fillColor); - drawList.AddCircle(drawPos, radiusPx, outlineColor, 0, 2.0f); - - var label = "LightFinder"; - var icon = FontAwesomeIcon.Bullseye.ToIconString(); - - ImGui.PushFont(UiBuilder.IconFont); - var iconSize = ImGui.CalcTextSize(icon); - var iconPos = new Vector2(drawPos.X - iconSize.X / 2f, drawPos.Y - radiusPx - iconSize.Y - 2f); - drawList.AddText(iconPos, fillColor, icon); - ImGui.PopFont(); - - /* var scale = 1.4f; - var font = ImGui.GetFont(); - var baseFontSize = ImGui.GetFontSize(); - var fontSize = baseFontSize * scale; - - var baseTextSize = ImGui.CalcTextSize(label); - var textSize = baseTextSize * scale; - - var textPos = new Vector2( - drawPos.X - textSize.X / 2f, - drawPos.Y - radiusPx - textSize.Y - 2f - ); - - drawList.AddText(font, fontSize, textPos, fillColor, label); */ + _addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour); + _mEnabled = true; + } + catch (Exception e) + { + _logger.LogError(e, "Unknown error while trying to enable nameplate."); + DisableNameplate(); } } + } - // Get screen offset based on distance to local player (to scale size appropriately) - // I need to fine tune these values still - private float GetScreenOffset(IPlayerCharacter player) + internal void DisableNameplate() + { + if (_mEnabled) { - var local = _gameObjects.LocalPlayer; - if (local == null) - return 32.1f; + try + { + _addonLifecycle.UnregisterListener(NameplateDrawDetour); + } + catch (Exception e) + { + _logger.LogError(e, "Unknown error while unregistering nameplate listener."); + } - var delta = player.Position - local.Position; - var dist = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); + _mEnabled = false; + ClearNameplateCaches(); + } + } - const float minDist = 2.1f; - const float maxDist = 30.4f; - dist = Math.Clamp(dist, minDist, maxDist); - - var t = 1f - (dist - minDist) / (maxDist - minDist); - - const float minOffset = 24.4f; - const float maxOffset = 56.4f; - return minOffset + (maxOffset - minOffset) * t; + private void NameplateDrawDetour(AddonEvent type, AddonArgs args) + { + if (args.Addon.Address == nint.Zero) + { + if (_logger.IsEnabled(LogLevel.Warning)) + _logger.LogWarning("Nameplate draw detour received a null addon address, skipping update."); + return; } - private bool TryGetApproxNameplateScreenPos(IPlayerCharacter player, out Vector2 screenPos) + var pNameplateAddon = (AddonNamePlate*)args.Addon.Address; + + if (_mpNameplateAddon != pNameplateAddon) { - screenPos = default; + ClearNameplateCaches(); + _mpNameplateAddon = pNameplateAddon; + } - var worldPos = player.Position; + UpdateNameplateNodes(); + } - var visualHeight = GetVisualHeight(player); + private void UpdateNameplateNodes() + { + var currentHandle = _gameGui.GetAddonByName("NamePlate"); + if (currentHandle.Address == nint.Zero) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh."); + ClearLabelBuffer(); + return; + } - worldPos.Y += (visualHeight + 1.2f) + _defaultHeightOffset; + var currentAddon = (AddonNamePlate*)currentHandle.Address; + if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon) + { + if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon); + return; + } - if (!_gameGui.WorldToScreen(worldPos, out var raw)) - return false; + var framework = Framework.Instance(); + if (framework == null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Framework instance unavailable during nameplate update, skipping."); + return; + } - screenPos = raw; + var uiModule = framework->GetUIModule(); + if (uiModule == null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("UI module unavailable during nameplate update, skipping."); + return; + } + + var ui3DModule = uiModule->GetUI3DModule(); + if (ui3DModule == null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("UI3D module unavailable during nameplate update, skipping."); + return; + } + + var vec = ui3DModule->NamePlateObjectInfoPointers; + if (vec.IsEmpty) + { + ClearLabelBuffer(); + return; + } + + var visibleUserIdsSnapshot = VisibleUserIds; + var safeCount = System.Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length); + var currentConfig = _configService.Current; + var labelColor = UIColors.Get("Lightfinder"); + var edgeColor = UIColors.Get("LightfinderEdge"); + var scratchCount = 0; + + for (int i = 0; i < safeCount; ++i) + { + var objectInfoPtr = vec[i]; + if (objectInfoPtr == null) + continue; + + var objectInfo = objectInfoPtr.Value; + if (objectInfo == null || objectInfo->GameObject == null) + continue; + + var nameplateIndex = objectInfo->NamePlateIndex; + if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects) + continue; + + var gameObject = objectInfo->GameObject; + if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player) + continue; + + // CID gating + var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject); + if (cid == null || !_activeBroadcastingCids.Contains(cid)) + continue; + + var local = _objectTable.LocalPlayer; + if (!currentConfig.LightfinderLabelShowOwn && local != null && + objectInfo->GameObject->GetGameObjectId() == local.GameObjectId) + continue; + + var hidePaired = !currentConfig.LightfinderLabelShowPaired; + var goId = gameObject->GetGameObjectId(); + if (hidePaired && visibleUserIdsSnapshot.Contains(goId)) + continue; + + var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex]; + 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) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex); + continue; + } + + root->Component->UldManager.UpdateDrawNodeList(); + + bool isVisible = + (marker != null && marker->AtkResNode.IsVisible()) || + (nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) || + currentConfig.LightfinderLabelShowHidden; + + if (!isVisible) + continue; + + var scaleMultiplier = System.Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f); + var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f; + var effectiveScale = baseScale * scaleMultiplier; + var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f; + var targetFontSize = (int)System.Math.Round(baseFontSize * scaleMultiplier); + var labelContent = currentConfig.LightfinderLabelUseIcon + ? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph) + : DefaultLabelText; + + if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal))) + labelContent = DefaultLabelText; + + var nodeWidth = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale); + var nodeHeight = (int)System.Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale); + AlignmentType alignment; + + var textScaleY = nameText->AtkResNode.ScaleY; + if (textScaleY <= 0f) + textScaleY = 1f; + + var blockHeight = ResolveCache( + _buffers.TextHeights, + nameplateIndex, + System.Math.Abs((int)nameplateObject.TextH), + () => GetScaledTextHeight(nameText), + nodeHeight); + + var containerHeight = ResolveCache( + _buffers.ContainerHeights, + nameplateIndex, + (int)nameContainer->Height, + () => + { + var computed = blockHeight + (int)System.Math.Round(8 * textScaleY); + return computed <= blockHeight ? blockHeight + 1 : computed; + }, + blockHeight + 1); + + var blockTop = containerHeight - blockHeight; + if (blockTop < 0) + blockTop = 0; + var verticalPadding = (int)System.Math.Round(4 * effectiveScale); + + var positionY = blockTop - verticalPadding; + + var rawTextWidth = (int)nameplateObject.TextW; + var textWidth = ResolveCache( + _buffers.TextWidths, + nameplateIndex, + System.Math.Abs(rawTextWidth), + () => GetScaledTextWidth(nameText), + nodeWidth); + + var textOffset = (int)System.Math.Round(nameText->AtkResNode.X); + var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset); + + if (nameContainer == null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Nameplate {Index} container became unavailable during update, skipping.", nameplateIndex); + continue; + } + + float finalX; + if (currentConfig.LightfinderAutoAlign) + { + var measuredWidth = System.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; + + var containerLeft = nameContainer->ScreenX; + var containerRight = containerLeft + containerWidth; + var containerCenter = containerLeft + (containerWidth * 0.5f); + + var iconMargin = currentConfig.LightfinderLabelUseIcon + ? System.Math.Min(containerWidth * 0.1f, 14f * containerScale) + : 0f; + + switch (alignmentType) + { + case LabelAlignment.Left: + finalX = containerLeft + iconMargin; + alignment = AlignmentType.BottomLeft; + break; + case LabelAlignment.Right: + finalX = containerRight - iconMargin; + alignment = AlignmentType.BottomRight; + break; + default: + finalX = containerCenter; + alignment = AlignmentType.Bottom; + break; + } + + finalX += currentConfig.LightfinderLabelOffsetX; + } + else + { + 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; + alignment = AlignmentType.Bottom; + } + + positionY += currentConfig.LightfinderLabelOffsetY; + alignment = (AlignmentType)System.Math.Clamp((int)alignment, 0, 8); + + var finalPosition = new Vector2(finalX, nameContainer->ScreenY + positionY); + var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon) + ? AlignmentToPivot(alignment) + : DefaultPivot; + var textColorPacked = PackColor(labelColor); + var edgeColorPacked = PackColor(edgeColor); + + _buffers.LabelScratch[scratchCount++] = new NameplateLabelInfo( + finalPosition, + labelContent, + textColorPacked, + edgeColorPacked, + targetFontSize, + pivot, + currentConfig.LightfinderLabelUseIcon); + } + + lock (_labelLock) + { + if (scratchCount == 0) + { + _labelRenderCount = 0; + } + else + { + Array.Copy(_buffers.LabelScratch, _buffers.LabelRender, scratchCount); + _labelRenderCount = scratchCount; + } + } + } + + private void OnUiBuilderDraw() + { + if (!_mEnabled) + return; + + int copyCount; + lock (_labelLock) + { + copyCount = _labelRenderCount; + if (copyCount == 0) + return; + + Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount); + } + + using var drawList = PictoService.Draw(); + if (drawList == null) + return; + + for (int i = 0; i < copyCount; ++i) + { + ref var info = ref _buffers.LabelCopy[i]; + var font = default(ImFontPtr); + if (info.UseIcon) + { + var ioFonts = ImGui.GetIO().Fonts; + font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont(); + } + + drawList.AddScreenText(info.ScreenPosition, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font); + } + } + + private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch + { + AlignmentType.BottomLeft => new Vector2(0f, 1f), + AlignmentType.BottomRight => new Vector2(1f, 1f), + AlignmentType.TopLeft => new Vector2(0f, 0f), + AlignmentType.TopRight => new Vector2(1f, 0f), + AlignmentType.Top => new Vector2(0.5f, 0f), + AlignmentType.Left => new Vector2(0f, 0.5f), + AlignmentType.Right => new Vector2(1f, 0.5f), + _ => 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); + return (uint)((a << 24) | (b << 16) | (g << 8) | r); + } + + private void ClearLabelBuffer() + { + lock (_labelLock) + { + _labelRenderCount = 0; + } + } + + private static unsafe int GetScaledTextHeight(AtkTextNode* node) + { + if (node == null) + return 0; + + var resNode = &node->AtkResNode; + var rawHeight = (int)resNode->GetHeight(); + if (rawHeight <= 0 && node->LineSpacing > 0) + rawHeight = node->LineSpacing; + if (rawHeight <= 0) + rawHeight = AtkNodeHelpers.DefaultTextNodeHeight; + + var scale = resNode->ScaleY; + if (scale <= 0f) + scale = 1f; + + var computed = (int)System.Math.Round(rawHeight * scale); + return System.Math.Max(1, computed); + } + + private static unsafe int GetScaledTextWidth(AtkTextNode* node) + { + if (node == null) + return 0; + + var resNode = &node->AtkResNode; + var rawWidth = (int)resNode->GetWidth(); + if (rawWidth <= 0) + rawWidth = AtkNodeHelpers.DefaultTextNodeWidth; + + var scale = resNode->ScaleX; + if (scale <= 0f) + scale = 1f; + + var computed = (int)System.Math.Round(rawWidth * scale); + return System.Math.Max(1, computed); + } + + private static int ResolveCache( + int[] cache, + int index, + int rawValue, + Func fallback, + int fallbackWhenZero) + { + if (rawValue > 0) + { + cache[index] = rawValue; + return rawValue; + } + + var cachedValue = cache[index]; + if (cachedValue > 0) + return cachedValue; + + var computed = fallback(); + if (computed <= 0) + computed = fallbackWhenZero; + + cache[index] = computed; + return computed; + } + + private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset) + { + if (System.Math.Abs(measuredTextWidth) > 0 || textOffset != 0) + { + _buffers.TextOffsets[nameplateIndex] = textOffset; return true; } - // Approximate check to see if nameplate would be visible based on distance and screen position - // Also has to be fine tuned still - private bool ShouldApproximateNameplateVisible(IPlayerCharacter player) + return false; + } + + internal static string NormalizeIconGlyph(string? rawInput) + { + if (string.IsNullOrWhiteSpace(rawInput)) + return DefaultIconGlyph; + + var trimmed = rawInput.Trim(); + + if (Enum.TryParse(trimmed, true, out var iconEnum)) + return SeIconCharExtensions.ToIconString(iconEnum); + + var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? trimmed[2..] + : trimmed; + + if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue)) + return char.ConvertFromUtf32(hexValue); + + var enumerator = trimmed.EnumerateRunes(); + if (enumerator.MoveNext()) + return enumerator.Current.ToString(); + + return DefaultIconGlyph; + } + + 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; + } + private readonly struct NameplateLabelInfo + { + public NameplateLabelInfo( + Vector2 screenPosition, + string text, + uint textColor, + uint edgeColor, + float fontSize, + Vector2 pivot, + bool useIcon) { - var local = _gameObjects.LocalPlayer; - if (local == null) - return false; - - var delta = player.Position - local.Position; - var distance2D = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); - if (distance2D > _defaultNameplateDistance) - return false; - - var verticalDelta = MathF.Abs(delta.Y); - if (verticalDelta > 3.4f) - return false; - - return TryGetApproxNameplateScreenPos(player, out _); + ScreenPosition = screenPosition; + Text = text; + TextColor = textColor; + EdgeColor = edgeColor; + FontSize = fontSize; + Pivot = pivot; + UseIcon = useIcon; } - private static unsafe float GetVisualHeight(IPlayerCharacter player) + public Vector2 ScreenPosition { get; } + public string Text { get; } + public uint TextColor { get; } + public uint EdgeColor { get; } + public float FontSize { get; } + public Vector2 Pivot { get; } + public bool UseIcon { get; } + } + + private HashSet VisibleUserIds + => [.. _pairUiService.GetSnapshot().PairsByUid.Values + .Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue) + .Select(u => (ulong)u.PlayerCharacterId)]; + + public void FlagRefresh() + { + _needsLabelRefresh = true; + } + + public void OnTick(PriorityFrameworkUpdateMessage _) + { + if (_needsLabelRefresh) { - var gameObject = (GameObject*)player.Address; - if (gameObject == null) - return Math.Max(player.HitboxRadius * 2.0f, 1.7f); // fallback - - // This should account for transformations (sitting, crouching, etc.) - var radius = gameObject->GetRadius(adjustByTransformation: true); - if (radius <= 0) - radius = Math.Max(player.HitboxRadius * 2.0f, 1.7f); - - return radius; - } - - // Update the set of active broadcasting CIDs (Same uses as in NameplateHnadler before) - public void UpdateBroadcastingCids(IEnumerable cids) - { - var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); - if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) - return; - - _activeBroadcastingCids = newSet; - if (_logger.IsEnabled(LogLevel.Information)) - _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); + UpdateNameplateNodes(); + _needsLabelRefresh = false; } } + + public void UpdateBroadcastingCids(IEnumerable cids) + { + var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); + if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) + return; + + _activeBroadcastingCids = newSet; + if (_logger.IsEnabled(LogLevel.Information)) + _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); + FlagRefresh(); + } + + public void ClearNameplateCaches() + { + _buffers.Clear(); + ClearLabelBuffer(); + } + + private sealed class NameplateBuffers + { + public NameplateBuffers() + { + TextOffsets = new int[AddonNamePlate.NumNamePlateObjects]; + System.Array.Fill(TextOffsets, int.MinValue); + } + + public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects]; + public int[] TextHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects]; + public int[] ContainerHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects]; + public int[] TextOffsets { get; } + public NameplateLabelInfo[] LabelScratch { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; + public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; + public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects]; + + public void Clear() + { + System.Array.Clear(TextWidths, 0, TextWidths.Length); + System.Array.Clear(TextHeights, 0, TextHeights.Length); + System.Array.Clear(ContainerHeights, 0, ContainerHeights.Length); + System.Array.Fill(TextOffsets, int.MinValue); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + Init(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Uninit(); + return Task.CompletedTask; + } + } \ No newline at end of file diff --git a/LightlessSync/Services/Rendering/PctDrawListExtensions.cs b/LightlessSync/Services/Rendering/PctDrawListExtensions.cs new file mode 100644 index 0000000..20bcb45 --- /dev/null +++ b/LightlessSync/Services/Rendering/PctDrawListExtensions.cs @@ -0,0 +1,82 @@ +using System.Numerics; +using System.Reflection; +using Dalamud.Bindings.ImGui; +using Pictomancy; + +namespace LightlessSync.Services.Rendering; + +internal static class PctDrawListExtensions +{ + private static readonly FieldInfo? DrawListField = typeof(PctDrawList).GetField("_drawList", BindingFlags.Instance | BindingFlags.NonPublic); + + private static bool TryGetImDrawList(PctDrawList drawList, out ImDrawListPtr ptr) + { + ptr = default; + if (DrawListField == null) + return false; + + if (DrawListField.GetValue(drawList) is ImDrawListPtr list) + { + ptr = list; + return true; + } + + return false; + } + + public static void AddScreenText(this PctDrawList drawList, Vector2 screenPosition, string text, uint color, float fontSize, Vector2? pivot = null, uint? outlineColor = null, ImFontPtr fontOverride = default) + { + if (drawList == null || string.IsNullOrEmpty(text)) + return; + + if (!TryGetImDrawList(drawList, out var imDrawList)) + return; + + var font = fontOverride.IsNull ? ImGui.GetFont() : fontOverride; + if (font.IsNull) + return; + + var size = MathF.Max(1f, fontSize); + var pivotValue = pivot ?? new Vector2(0.5f, 0.5f); + + Vector2 measured; + float calcFontSize; + if (!fontOverride.IsNull) + { + ImGui.PushFont(font); + measured = ImGui.CalcTextSize(text); + calcFontSize = ImGui.GetFontSize(); + ImGui.PopFont(); + } + else + { + measured = ImGui.CalcTextSize(text); + calcFontSize = ImGui.GetFontSize(); + } + + if (calcFontSize > 0f && MathF.Abs(size - calcFontSize) > 0.001f) + { + measured *= size / calcFontSize; + } + + var drawPos = screenPosition - measured * pivotValue; + if (outlineColor.HasValue) + { + var thickness = MathF.Max(1f, size / 24f); + Span offsets = stackalloc Vector2[4] + { + new Vector2(1f, 0f), + new Vector2(-1f, 0f), + new Vector2(0f, 1f), + new Vector2(0f, -1f) + }; + + foreach (var offset in offsets) + { + imDrawList.AddText(font, size, drawPos + offset * thickness, outlineColor.Value, text); + } + } + + imDrawList.AddText(font, size, drawPos, color, text); + } +} diff --git a/LightlessSync/Services/Rendering/PictomancyService.cs b/LightlessSync/Services/Rendering/PictomancyService.cs new file mode 100644 index 0000000..7d12b4c --- /dev/null +++ b/LightlessSync/Services/Rendering/PictomancyService.cs @@ -0,0 +1,47 @@ +using Dalamud.Plugin; +using Microsoft.Extensions.Logging; +using Pictomancy; + +namespace LightlessSync.Services.Rendering; + +public sealed class PictomancyService : IDisposable +{ + private readonly ILogger _logger; + private bool _initialized; + + public PictomancyService(ILogger logger, IDalamudPluginInterface pluginInterface) + { + _logger = logger; + + try + { + PictoService.Initialize(pluginInterface); + _initialized = true; + if (_logger.IsEnabled(LogLevel.Debug)) + _logger.LogDebug("Pictomancy initialized"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize Pictomancy"); + } + } + + public void Dispose() + { + if (!_initialized) + return; + + try + { + PictoService.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispose Pictomancy"); + } + finally + { + _initialized = false; + } + } +} diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index b43b1b5..a7b42f5 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -1,8 +1,9 @@ -using System; using System.Collections.Concurrent; +using System.Buffers; using System.Buffers.Binary; using System.Globalization; using System.IO; +using System.Runtime.InteropServices; using OtterTex; using OtterImage = OtterTex.Image; using LightlessSync.LightlessConfiguration; @@ -11,7 +12,6 @@ using Microsoft.Extensions.Logging; using Lumina.Data.Files; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; /* * OtterTex made by Ottermandias @@ -33,6 +33,7 @@ public sealed class TextureDownscaleService private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _downscaledPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _downscaleSemaphore = new(4); private static readonly IReadOnlyDictionary BlockCompressedFormatMap = new Dictionary { @@ -80,7 +81,10 @@ public sealed class TextureDownscaleService if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; if (_activeJobs.ContainsKey(hash)) return; - _activeJobs[hash] = Task.Run(() => DownscaleInternalAsync(hash, filePath, mapKind), CancellationToken.None); + _activeJobs[hash] = Task.Run(async () => + { + await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); + }, CancellationToken.None); } public string GetPreferredPath(string hash, string originalPath) @@ -108,6 +112,7 @@ public sealed class TextureDownscaleService bool onlyDownscaleUncompressed = false; bool? isIndexTexture = null; + await _downscaleSemaphore.WaitAsync().ConfigureAwait(false); try { if (!File.Exists(sourcePath)) @@ -157,6 +162,15 @@ public sealed class TextureDownscaleService return; } + if (headerInfo is { } headerValue && + headerValue.Width <= targetMaxDimension && + headerValue.Height <= targetMaxDimension) + { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height); + return; + } + if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format)) { _downscaledPaths[hash] = sourcePath; @@ -172,21 +186,20 @@ public sealed class TextureDownscaleService var height = rgbaInfo.Meta.Height; var requiredLength = width * height * bytesPerPixel; - var rgbaPixels = rgbaScratch.Pixels[..requiredLength].ToArray(); + var rgbaPixels = rgbaScratch.Pixels.Slice(0, requiredLength); using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData(rgbaPixels, width, height); var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension); if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height) { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash); return; } using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple); - var resizedPixels = new byte[targetSize.width * targetSize.height * 4]; - resized.CopyPixelDataTo(resizedPixels); - - using var resizedScratch = ScratchImage.FromRGBA(resizedPixels, targetSize.width, targetSize.height, out var creationInfo).ThrowIfError(creationInfo); + using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height); using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); TexFileHelper.Save(destination, finalScratch); @@ -209,6 +222,7 @@ public sealed class TextureDownscaleService } finally { + _downscaleSemaphore.Release(); _activeJobs.TryRemove(hash, out _); } } @@ -227,6 +241,41 @@ public sealed class TextureDownscaleService return (resultWidth, resultHeight); } + private static ScratchImage CreateScratchImage(Image image, int width, int height) + { + const int BytesPerPixel = 4; + var requiredLength = width * height * BytesPerPixel; + + static ScratchImage Create(ReadOnlySpan pixels, int width, int height) + { + var scratchResult = ScratchImage.FromRGBA(pixels, width, height, out var creationInfo); + return scratchResult.ThrowIfError(creationInfo); + } + + if (image.DangerousTryGetSinglePixelMemory(out var pixelMemory)) + { + var byteSpan = MemoryMarshal.AsBytes(pixelMemory.Span); + if (byteSpan.Length < requiredLength) + { + throw new InvalidOperationException($"Image buffer shorter than expected ({byteSpan.Length} < {requiredLength})."); + } + + return Create(byteSpan.Slice(0, requiredLength), width, height); + } + + var rented = ArrayPool.Shared.Rent(requiredLength); + try + { + var rentedSpan = rented.AsSpan(0, requiredLength); + image.CopyPixelDataTo(rentedSpan); + return Create(rentedSpan, width, height); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + private static bool IsIndexMap(TextureMapKind kind) => kind is TextureMapKind.Mask or TextureMapKind.Index; @@ -420,21 +469,6 @@ public sealed class TextureDownscaleService private static int ReduceDimension(int value) => value <= 1 ? 1 : Math.Max(1, value / 2); - private static Image ReduceLinearTexture(Image source, int targetWidth, int targetHeight) - { - var clone = source.Clone(); - - while (clone.Width > targetWidth || clone.Height > targetHeight) - { - var nextWidth = Math.Max(targetWidth, Math.Max(BlockMultiple, clone.Width / 2)); - var nextHeight = Math.Max(targetHeight, Math.Max(BlockMultiple, clone.Height / 2)); - clone.Mutate(ctx => ctx.Resize(nextWidth, nextHeight, KnownResamplers.Lanczos3)); - } - - return clone; - } - - private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension) { var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1; @@ -443,12 +477,7 @@ public sealed class TextureDownscaleService private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension) { - if (width <= targetMaxDimension || height <= targetMaxDimension) - { - return false; - } - - if (depth > 1 && depth <= targetMaxDimension) + if (width <= targetMaxDimension && height <= targetMaxDimension && depth <= targetMaxDimension) { return false; } diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index 28f3053..b31b145 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -51,10 +51,11 @@ public class IdDisplayHandler (bool textIsUid, string playerText) = GetGroupText(group); if (!string.Equals(_editEntry, group.GID, StringComparison.Ordinal)) { - ImGui.AlignTextToFramePadding(); - using (ImRaii.PushFont(UiBuilder.MonoFont, textIsUid)) + { + ImGui.AlignTextToFramePadding(); ImGui.TextUnformatted(playerText); + } if (ImGui.IsItemHovered()) { @@ -121,108 +122,113 @@ public class IdDisplayHandler if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal)) { - ImGui.AlignTextToFramePadding(); - var rowStart = ImGui.GetCursorScreenPos(); - var rowWidth = MathF.Max(editBoxWidth.Invoke(), 0f); - var rowRightLimit = rowStart.X + rowWidth; - var font = textIsUid ? UiBuilder.MonoFont : ImGui.GetFont(); + var rowWidth = MathF.Max(editBoxWidth.Invoke(), 0f); + float rowRightLimit = 0f; + Vector2 nameRectMin = Vector2.Zero; + Vector2 nameRectMax = Vector2.Zero; + float rowTopForStats = 0f; + float frameHeightForStats = 0f; - Vector4? textColor = null; - Vector4? glowColor = null; - - if (pair.UserData.HasVanity) - { - if (!string.IsNullOrWhiteSpace(pair.UserData.TextColorHex)) - { - textColor = UIColors.HexToRgba(pair.UserData.TextColorHex); - } - - if (!string.IsNullOrWhiteSpace(pair.UserData.TextGlowColorHex)) - { - glowColor = UIColors.HexToRgba(pair.UserData.TextGlowColorHex); - } - } - - var useVanityColors = _lightlessConfigService.Current.useColoredUIDs && (textColor != null || glowColor != null); - var seString = useVanityColors - ? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor) - : SeStringUtils.BuildPlain(playerText); - - var drawList = ImGui.GetWindowDrawList(); - bool useHighlight = false; - float highlightPadX = 0f; - float highlightPadY = 0f; - - if (useVanityColors) - { - float boost = Luminance.ComputeHighlight(textColor, glowColor); - - if (boost > 0f) - { - var style = ImGui.GetStyle(); - useHighlight = true; - highlightPadX = MathF.Max(style.FramePadding.X * 0.6f, 2f * ImGuiHelpers.GlobalScale); - highlightPadY = MathF.Max(style.FramePadding.Y * 0.55f, 1.25f * ImGuiHelpers.GlobalScale); - drawList.ChannelsSplit(2); - drawList.ChannelsSetCurrent(1); - - _highlightBoost = boost; - } - else - { - _highlightBoost = 0f; - } - } - - Vector2 itemMin; - Vector2 itemMax; using (ImRaii.PushFont(font, textIsUid)) { - SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID); - itemMin = ImGui.GetItemRectMin(); - itemMax = ImGui.GetItemRectMax(); - } + ImGui.AlignTextToFramePadding(); + var rowStart = ImGui.GetCursorScreenPos(); + rowRightLimit = rowStart.X + rowWidth; - if (useHighlight) + Vector4? textColor = null; + Vector4? glowColor = null; + + if (pair.UserData.HasVanity) + { + if (!string.IsNullOrWhiteSpace(pair.UserData.TextColorHex)) + { + textColor = UIColors.HexToRgba(pair.UserData.TextColorHex); + } + + if (!string.IsNullOrWhiteSpace(pair.UserData.TextGlowColorHex)) + { + glowColor = UIColors.HexToRgba(pair.UserData.TextGlowColorHex); + } + } + + var useVanityColors = _lightlessConfigService.Current.useColoredUIDs && (textColor != null || glowColor != null); + var seString = useVanityColors + ? SeStringUtils.BuildFormattedPlayerName(playerText, textColor, glowColor) + : SeStringUtils.BuildPlain(playerText); + + var drawList = ImGui.GetWindowDrawList(); + bool useHighlight = false; + float highlightPadX = 0f; + float highlightPadY = 0f; + + if (useVanityColors) + { + float boost = Luminance.ComputeHighlight(textColor, glowColor); + + if (boost > 0f) + { + var style = ImGui.GetStyle(); + useHighlight = true; + highlightPadX = MathF.Max(style.FramePadding.X * 0.6f, 2f * ImGuiHelpers.GlobalScale); + highlightPadY = MathF.Max(style.FramePadding.Y * 0.55f, 1.25f * ImGuiHelpers.GlobalScale); + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + _highlightBoost = boost; + } + else + { + _highlightBoost = 0f; + } + } + + SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID); + nameRectMin = ImGui.GetItemRectMin(); + nameRectMax = ImGui.GetItemRectMax(); + + if (useHighlight) + { + var style = ImGui.GetStyle(); + var frameHeight = ImGui.GetFrameHeight(); + var rowTop = rowStart.Y - style.FramePadding.Y; + var rowBottom = rowTop + frameHeight; + + var highlightMin = new Vector2(nameRectMin.X - highlightPadX, rowTop - highlightPadY); + var highlightMax = new Vector2(nameRectMax.X + highlightPadX, rowBottom + highlightPadY); + + var windowPos = ImGui.GetWindowPos(); + var contentMin = windowPos + ImGui.GetWindowContentRegionMin(); + var contentMax = windowPos + ImGui.GetWindowContentRegionMax(); + highlightMin.X = MathF.Max(highlightMin.X, contentMin.X); + highlightMax.X = MathF.Min(highlightMax.X, contentMax.X); + highlightMin.Y = MathF.Max(highlightMin.Y, contentMin.Y); + highlightMax.Y = MathF.Min(highlightMax.Y, contentMax.Y); + + var highlightColor = new Vector4( + 0.25f + _highlightBoost, + 0.25f + _highlightBoost, + 0.25f + _highlightBoost, + 1f + ); + + highlightColor = Luminance.BackgroundContrast(textColor, glowColor, highlightColor, ref _currentBg); + + float rounding = style.FrameRounding > 0f ? style.FrameRounding : 5f * ImGuiHelpers.GlobalScale; + drawList.ChannelsSetCurrent(0); + drawList.AddRectFilled(highlightMin, highlightMax, ImGui.GetColorU32(highlightColor), rounding); + + var borderColor = style.Colors[(int)ImGuiCol.Border]; + borderColor.W *= 0.25f; + drawList.AddRect(highlightMin, highlightMax, ImGui.GetColorU32(borderColor), rounding); + drawList.ChannelsMerge(); + } + } { var style = ImGui.GetStyle(); - var frameHeight = ImGui.GetFrameHeight(); - var rowTop = rowStart.Y - style.FramePadding.Y; - var rowBottom = rowTop + frameHeight; - - var highlightMin = new Vector2(itemMin.X - highlightPadX, rowTop - highlightPadY); - var highlightMax = new Vector2(itemMax.X + highlightPadX, rowBottom + highlightPadY); - - var windowPos = ImGui.GetWindowPos(); - var contentMin = windowPos + ImGui.GetWindowContentRegionMin(); - var contentMax = windowPos + ImGui.GetWindowContentRegionMax(); - highlightMin.X = MathF.Max(highlightMin.X, contentMin.X); - highlightMax.X = MathF.Min(highlightMax.X, contentMax.X); - highlightMin.Y = MathF.Max(highlightMin.Y, contentMin.Y); - highlightMax.Y = MathF.Min(highlightMax.Y, contentMax.Y); - - var highlightColor = new Vector4( - 0.25f + _highlightBoost, - 0.25f + _highlightBoost, - 0.25f + _highlightBoost, - 1f - ); - - highlightColor = Luminance.BackgroundContrast(textColor, glowColor, highlightColor, ref _currentBg); - - float rounding = style.FrameRounding > 0f ? style.FrameRounding : 5f * ImGuiHelpers.GlobalScale; - drawList.ChannelsSetCurrent(0); - drawList.AddRectFilled(highlightMin, highlightMax, ImGui.GetColorU32(highlightColor), rounding); - - var borderColor = style.Colors[(int)ImGuiCol.Border]; - borderColor.W *= 0.25f; - drawList.AddRect(highlightMin, highlightMax, ImGui.GetColorU32(borderColor), rounding); - drawList.ChannelsMerge(); + frameHeightForStats = ImGui.GetFrameHeight(); + rowTopForStats = nameRectMin.Y - style.FramePadding.Y; } - - var nameRectMin = ImGui.GetItemRectMin(); - var nameRectMax = ImGui.GetItemRectMax(); if (ImGui.IsItemHovered()) { if (!string.Equals(_lastMouseOverUid, id, StringComparison.Ordinal)) @@ -292,12 +298,9 @@ public class IdDisplayHandler const float compactFontScale = 0.85f; ImGui.SetWindowFontScale(compactFontScale); var compactHeight = ImGui.GetTextLineHeight(); - var nameHeight = nameRectMax.Y - nameRectMin.Y; var targetPos = ImGui.GetCursorScreenPos(); var availableWidth = MathF.Max(rowRightLimit - targetPos.X, 0f); - var centeredY = nameRectMin.Y + MathF.Max((nameHeight - compactHeight) * 0.5f, 0f); - float verticalOffset = 1f * ImGuiHelpers.GlobalScale; - centeredY += verticalOffset; + var centeredY = rowTopForStats + MathF.Max((frameHeightForStats - compactHeight) * 0.5f, 0f); ImGui.SetCursorScreenPos(new Vector2(targetPos.X, centeredY)); var performanceText = string.Empty; diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 697f100..084b55b 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -48,7 +48,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly ImGuiWindowFlags _unpinnedWindowFlags; private float _currentWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; - private bool _showRulesOverlay = true; + private bool _showRulesOverlay; private string? _selectedChannelKey; private bool _scrollToBottom = true; @@ -165,7 +165,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private void DrawHeader(ChatChannelSnapshot channel) + private static void DrawHeader(ChatChannelSnapshot channel) { var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; Vector4 color; @@ -577,12 +577,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.Separator(); ImGui.TextUnformatted("Reason (required)"); - if (ImGui.InputTextMultiline("##chat_report_reason", ref _reportReason, ReportReasonMaxLength, new Vector2(-1, 80f * ImGuiHelpers.GlobalScale))) + if (ImGui.InputTextMultiline("##chat_report_reason", ref _reportReason, ReportReasonMaxLength, new Vector2(-1, 80f * ImGuiHelpers.GlobalScale)) + && _reportReason.Length > ReportReasonMaxLength) { - if (_reportReason.Length > ReportReasonMaxLength) - { - _reportReason = _reportReason[..(int)ReportReasonMaxLength]; - } + _reportReason = _reportReason[..ReportReasonMaxLength]; } ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); @@ -591,12 +589,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.Spacing(); ImGui.TextUnformatted("Additional context (optional)"); - if (ImGui.InputTextMultiline("##chat_report_context", ref _reportAdditionalContext, ReportContextMaxLength, new Vector2(-1, 120f * ImGuiHelpers.GlobalScale))) + if (ImGui.InputTextMultiline("##chat_report_context", ref _reportAdditionalContext, ReportContextMaxLength, new Vector2(-1, 120f * ImGuiHelpers.GlobalScale)) + && _reportAdditionalContext.Length > ReportContextMaxLength) { - if (_reportAdditionalContext.Length > ReportContextMaxLength) - { - _reportAdditionalContext = _reportAdditionalContext[..(int)ReportContextMaxLength]; - } + _reportAdditionalContext = _reportAdditionalContext[..ReportContextMaxLength]; } ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); @@ -768,7 +764,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) + private static bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) { var text = message.Payload.Message; if (string.IsNullOrEmpty(text)) @@ -920,7 +916,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void EnsureSelectedChannel(IReadOnlyList channels) { - if (_selectedChannelKey is not null && channels.Any(channel => channel.Key == _selectedChannelKey)) + if (_selectedChannelKey is not null && channels.Any(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal))) return; _selectedChannelKey = channels.Count > 0 ? channels[0].Key : null; @@ -1264,11 +1260,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); var showBadge = !isSelected && channel.UnreadCount > 0; var isZoneChannel = channel.Type == ChatChannelType.Zone; - var badgeText = string.Empty; - var badgePadding = Vector2.Zero; - var badgeTextSize = Vector2.Zero; - float badgeWidth = 0f; - float badgeHeight = 0f; + (string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null; var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); var hovered = isSelected @@ -1285,15 +1277,16 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (showBadge) { var badgeSpacing = 4f * ImGuiHelpers.GlobalScale; - badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; - badgeText = channel.UnreadCount > MaxBadgeDisplay + var badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; + var badgeText = channel.UnreadCount > MaxBadgeDisplay ? $"{MaxBadgeDisplay}+" : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); - badgeTextSize = ImGui.CalcTextSize(badgeText); - badgeWidth = badgeTextSize.X + badgePadding.X * 2f; - badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; + var badgeTextSize = ImGui.CalcTextSize(badgeText); + var badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + var badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; var customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y); ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding); + badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight); } var clicked = ImGui.Button($"{channel.DisplayName}##chat_channel_{channel.Key}"); @@ -1324,20 +1317,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness); } - if (showBadge) + if (showBadge && badgeMetrics is { } metrics) { var buttonSizeY = itemMax.Y - itemMin.Y; var badgeMin = new Vector2( itemMin.X + baseFramePadding.X, - itemMin.Y + (buttonSizeY - badgeHeight) * 0.5f); - var badgeMax = badgeMin + new Vector2(badgeWidth, badgeHeight); + itemMin.Y + (buttonSizeY - metrics.Height) * 0.5f); + var badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height); var badgeColor = UIColors.Get("DimRed"); var badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor); - drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, badgeHeight * 0.5f); + drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, metrics.Height * 0.5f); var textPos = new Vector2( - badgeMin.X + (badgeWidth - badgeTextSize.X) * 0.5f, - badgeMin.Y + (badgeHeight - badgeTextSize.Y) * 0.5f); - drawList.AddText(textPos, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), badgeText); + badgeMin.X + (metrics.Width - metrics.TextSize.X) * 0.5f, + badgeMin.Y + (metrics.Height - metrics.TextSize.Y) * 0.5f); + drawList.AddText(textPos, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), metrics.Text); } first = false; diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index c8b9a7b..c3e50fd 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -565,19 +565,30 @@ public static class SeStringUtils public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); - + var usedFont = font ?? UiBuilder.MonoFont; var drawParams = new SeStringDrawParams { - Font = font ?? UiBuilder.MonoFont, + Font = usedFont, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue, TargetDrawList = drawList }; - ImGui.SetCursorScreenPos(position); - ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); - var textSize = ImGui.CalcTextSize(seString.TextValue); + if (textSize.Y <= 0f) + { + textSize.Y = usedFont.FontSize; + } + + var style = ImGui.GetStyle(); + var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); + var frameHeight = fontHeight + style.FramePadding.Y * 2f; + var hitboxHeight = MathF.Max(frameHeight, textSize.Y); + var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f); + + var drawPos = new Vector2(position.X, position.Y + verticalOffset); + ImGui.SetCursorScreenPos(drawPos); + ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); ImGui.SetCursorScreenPos(position); if (id is not null) @@ -591,30 +602,52 @@ public static class SeStringUtils try { - ImGui.InvisibleButton("##hitbox", textSize); + ImGui.InvisibleButton("##hitbox", new Vector2(textSize.X, hitboxHeight)); } finally { ImGui.PopID(); } - return textSize; + return new Vector2(textSize.X, hitboxHeight); } public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); + var usedFont = font ?? UiBuilder.MonoFont; + var iconMacro = $""; - var drawParams = new SeStringDrawParams + var measureParams = new SeStringDrawParams { - Font = font ?? UiBuilder.MonoFont, + Font = usedFont, Color = 0xFFFFFFFF, - WrapWidth = float.MaxValue, - TargetDrawList = drawList + WrapWidth = float.MaxValue }; - var iconMacro = $""; - var drawResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); + var measureResult = ImGuiHelpers.CompileSeStringWrapped(iconMacro, measureParams); + var iconSize = measureResult.Size; + if (iconSize.Y <= 0f) + { + iconSize.Y = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); + } + + var style = ImGui.GetStyle(); + var fontHeight = usedFont.FontSize > 0f ? usedFont.FontSize : ImGui.GetFontSize(); + var frameHeight = fontHeight + style.FramePadding.Y * 2f; + var hitboxHeight = MathF.Max(frameHeight, iconSize.Y); + var verticalOffset = MathF.Max((hitboxHeight - iconSize.Y) * 0.5f, 0f); + + var drawPos = new Vector2(position.X, position.Y + verticalOffset); + var drawParams = new SeStringDrawParams + { + Font = usedFont, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + TargetDrawList = drawList, + ScreenOffset = drawPos + }; + ImGuiHelpers.CompileSeStringWrapped(iconMacro, drawParams); ImGui.SetCursorScreenPos(position); if (id is not null) @@ -628,14 +661,14 @@ public static class SeStringUtils try { - ImGui.InvisibleButton("##iconHitbox", drawResult.Size); + ImGui.InvisibleButton("##iconHitbox", new Vector2(iconSize.X, hitboxHeight)); } finally { ImGui.PopID(); } - return drawResult.Size; + return new Vector2(iconSize.X, hitboxHeight); } #region Internal Payloads diff --git a/LightlessSync/Utils/VariousExtensions.cs b/LightlessSync/Utils/VariousExtensions.cs index e0fd466..3f47d98 100644 --- a/LightlessSync/Utils/VariousExtensions.cs +++ b/LightlessSync/Utils/VariousExtensions.cs @@ -177,7 +177,8 @@ public static class VariousExtensions if (objectKind != ObjectKind.Player) continue; bool manipDataDifferent = !string.Equals(oldData.ManipulationData, newData.ManipulationData, StringComparison.Ordinal); - if (manipDataDifferent || forceApplyMods) + var hasManipulationData = !string.IsNullOrEmpty(newData.ManipulationData); + if (manipDataDifferent || (forceApplyMods && hasManipulationData)) { 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 181b02a..49dd868 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -12,7 +12,6 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Http.Json; using LightlessSync.LightlessConfiguration; -using LightlessSync.Services.PairProcessing; namespace LightlessSync.WebAPI.Files; @@ -22,12 +21,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; - private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly LightlessConfigService _configService; private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureMetadataHelper _textureMetadataHelper; private readonly ConcurrentDictionary _activeDownloadStreams; - private static readonly TimeSpan DownloadStallTimeout = TimeSpan.FromSeconds(30); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; private bool _lastConfigDirectDownloadsState; @@ -38,7 +35,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase FileTransferOrchestrator orchestrator, FileCacheManager fileCacheManager, FileCompactor fileCompactor, - PairProcessingLimiter pairProcessingLimiter, LightlessConfigService configService, TextureDownscaleService textureDownscaleService, TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) { @@ -46,7 +42,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; - _pairProcessingLimiter = pairProcessingLimiter; _configService = configService; _textureDownscaleService = textureDownscaleService; _textureMetadataHelper = textureMetadataHelper; @@ -282,42 +277,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase int bytesRead; try { - using var readCancellation = CancellationTokenSource.CreateLinkedTokenSource(ct); - var readTask = stream.ReadAsync(buffer.AsMemory(0, buffer.Length), readCancellation.Token).AsTask(); - while (!readTask.IsCompleted) - { - var completedTask = await Task.WhenAny(readTask, Task.Delay(DownloadStallTimeout)).ConfigureAwait(false); - if (completedTask == readTask) - { - break; - } - - ct.ThrowIfCancellationRequested(); - - var snapshot = _pairProcessingLimiter.GetSnapshot(); - if (snapshot.Waiting > 0) - { - readCancellation.Cancel(); - try - { - await readTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // expected when cancelling the read due to timeout - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Error finishing read task after stall detection for {requestUrl}", requestUrl); - } - - throw new TimeoutException($"No data received for {DownloadStallTimeout.TotalSeconds} seconds while downloading {requestUrl} (waiting: {snapshot.Waiting})"); - } - - Logger.LogTrace("Download stalled for {requestUrl} but no queued pairs, continuing to wait", requestUrl); - } - - bytesRead = await readTask.ConfigureAwait(false); + bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct).ConfigureAwait(false); } catch (OperationCanceledException ex) { @@ -340,11 +300,6 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogDebug("{requestUrl} downloaded to {destination}", requestUrl, destinationFilename); } } - catch (TimeoutException ex) - { - Logger.LogWarning(ex, "Detected stalled download for {requestUrl}, aborting transfer", requestUrl); - throw; - } catch (OperationCanceledException) { throw; diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index a109393..daa1008 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -521,26 +521,979 @@ "resolved": "17.6.3", "contentHash": "N0ZIanl1QCgvUumEL1laasU0a7sOE5ZwLZVTn0pAePnfhq8P7SvTjF8Axq+CnavuQkmdQpGNXQ1efZtu5kDFbA==" }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Microsoft.Win32.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.Win32.Primitives": "4.3.0", + "System.AppContext": "4.3.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Console": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.Compression.ZipFile": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.Net.Http": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Net.Sockets": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Timer": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0", + "System.Xml.XDocument": "4.3.0" + } + }, + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" + }, + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" + }, + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" + }, + "runtime.native.System": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", + "dependencies": { + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" + } + }, + "runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", + "dependencies": { + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" + }, + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" + }, + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" + }, + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" + }, + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" + }, + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" + }, + "SharpDX": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "3pv0LFMvfK/dv1qISJnn8xBeeT6R/FRvr0EV4KI2DGsL84Qlv6P7isWqxGyU0LCwlSVCJN3jgHJ4Bl0KI2PJww==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "SharpDX.D3DCompiler": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "Rnsd6Ilp127xbXqhTit8WKFQUrXwWxqVGpglyWDNkIBCk0tWXNQEjrJpsl0KAObzyZaa33+EXAikLVt5fnd3GA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "SharpDX.Direct2D1": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "Qs8LzDMaQf1u3KB8ArHu9pDv6itZ++QXs99a/bVAG+nKr0Hx5NG4mcN5vsfE0mVR2TkeHfeUm4PksRah6VUPtA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0", + "SharpDX.DXGI": "4.2.0" + } + }, + "SharpDX.Direct3D11": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "oTm/iT5X/IIuJ8kNYP+DTC/MhBhqtRF5dbgPPFgLBdQv0BKzNTzXQQXd7SveBFjQg6hXEAJ2jGCAzNYvGFc9LA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0", + "SharpDX.DXGI": "4.2.0" + } + }, + "SharpDX.DXGI": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "UjKqkgWc8U+SP+j3LBzFP6OB6Ntapjih7Xo+g1rLcsGbIb5KwewBrBChaUu7sil8rWoeVU/k0EJd3SMN4VqNZw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "SharpDX.Mathematics": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "R2pcKLgdsP9p5WyTjHmGOZ0ka0zASAZYc6P4L6rSvjYhf6klGYbent7MiVwbkwkt9dD44p5brjy5IwAnVONWGw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "System.AppContext": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Collections": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.Concurrent": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Console": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Diagnostics.Debug": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "9.0.3", "contentHash": "0nDJBZ06DVdTG2vvCZ4XjazLVaFawdT0pnji23ISX8I8fEOlRJyzH2I0kWiAbCtFwry2Zir4qE4l/GStLATfFw==" }, + "System.Diagnostics.Tools": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.Tracing": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Calendars": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Buffers": "4.3.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.IO.Compression": "4.3.0" + } + }, + "System.IO.Compression.ZipFile": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", + "dependencies": { + "System.Buffers": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.IO.FileSystem": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Linq.Expressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Linq": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Emit.Lightweight": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, "System.Memory": { "type": "Transitive", "resolved": "4.5.5", "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.DiagnosticSource": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Extensions": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Net.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, "System.Net.ServerSentEvents": { "type": "Transitive", "resolved": "9.0.3", "contentHash": "Vs/C2V27bjtwLqYag9ATzHilcUn8VQTICre4jSBMGFUeSTxEZffTjb+xZwjcmPsVAjmSZmBI5N7Ezq8UFvqQQg==" }, + "System.Net.Sockets": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.ObjectModel": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Reflection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.Lightweight": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Resources.ResourceManager": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0" + } + }, + "System.Runtime.Numerics": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", + "dependencies": { + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.Apple": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Security.Cryptography.Csp": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Linq": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", + "dependencies": { + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Cng": "4.3.0", + "System.Security.Cryptography.Csp": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Text.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Text.Encoding.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Threading": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, "System.Threading.Channels": { "type": "Transitive", "resolved": "9.0.3", "contentHash": "Ao0iegVONKYVw0eWxJv0ArtMVfkFjgyyYKtUXru6xX5H95flSZWW3QCavD4PAgwpc0ETP38kGHaYbPzSE7sw2w==" }, + "System.Threading.Tasks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Timer": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Xml.ReaderWriter": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Tasks.Extensions": "4.3.0" + } + }, + "System.Xml.XDocument": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0" + } + }, "lightlesssync.api": { "type": "Project", "dependencies": { @@ -569,6 +1522,15 @@ }, "penumbra.string": { "type": "Project" + }, + "pictomancy": { + "type": "Project", + "dependencies": { + "SharpDX.D3DCompiler": "[4.2.0, )", + "SharpDX.Direct2D1": "[4.2.0, )", + "SharpDX.Direct3D11": "[4.2.0, )", + "SharpDX.Mathematics": "[4.2.0, )" + } } } } diff --git a/Pictomancy/Pictomancy/packages.lock.json b/Pictomancy/Pictomancy/packages.lock.json new file mode 100644 index 0000000..95a3cd4 --- /dev/null +++ b/Pictomancy/Pictomancy/packages.lock.json @@ -0,0 +1,970 @@ +{ + "version": 1, + "dependencies": { + "net9.0-windows7.0": { + "DotNet.ReproducibleBuilds": { + "type": "Direct", + "requested": "[1.2.25, )", + "resolved": "1.2.25", + "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" + }, + "SharpDX.D3DCompiler": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "Rnsd6Ilp127xbXqhTit8WKFQUrXwWxqVGpglyWDNkIBCk0tWXNQEjrJpsl0KAObzyZaa33+EXAikLVt5fnd3GA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "SharpDX.Direct2D1": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "Qs8LzDMaQf1u3KB8ArHu9pDv6itZ++QXs99a/bVAG+nKr0Hx5NG4mcN5vsfE0mVR2TkeHfeUm4PksRah6VUPtA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0", + "SharpDX.DXGI": "4.2.0" + } + }, + "SharpDX.Direct3D11": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "oTm/iT5X/IIuJ8kNYP+DTC/MhBhqtRF5dbgPPFgLBdQv0BKzNTzXQQXd7SveBFjQg6hXEAJ2jGCAzNYvGFc9LA==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0", + "SharpDX.DXGI": "4.2.0" + } + }, + "SharpDX.Mathematics": { + "type": "Direct", + "requested": "[4.2.0, )", + "resolved": "4.2.0", + "contentHash": "R2pcKLgdsP9p5WyTjHmGOZ0ka0zASAZYc6P4L6rSvjYhf6klGYbent7MiVwbkwkt9dD44p5brjy5IwAnVONWGw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.NETCore.Targets": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" + }, + "Microsoft.Win32.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "NETStandard.Library": { + "type": "Transitive", + "resolved": "1.6.1", + "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.Win32.Primitives": "4.3.0", + "System.AppContext": "4.3.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Console": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.Compression.ZipFile": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Linq": "4.3.0", + "System.Linq.Expressions": "4.3.0", + "System.Net.Http": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Net.Sockets": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Timer": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0", + "System.Xml.XDocument": "4.3.0" + } + }, + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" + }, + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" + }, + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" + }, + "runtime.native.System": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", + "dependencies": { + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" + } + }, + "runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", + "dependencies": { + "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" + }, + "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" + }, + "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" + }, + "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" + }, + "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" + }, + "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" + }, + "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" + }, + "SharpDX": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "3pv0LFMvfK/dv1qISJnn8xBeeT6R/FRvr0EV4KI2DGsL84Qlv6P7isWqxGyU0LCwlSVCJN3jgHJ4Bl0KI2PJww==", + "dependencies": { + "NETStandard.Library": "1.6.1" + } + }, + "SharpDX.DXGI": { + "type": "Transitive", + "resolved": "4.2.0", + "contentHash": "UjKqkgWc8U+SP+j3LBzFP6OB6Ntapjih7Xo+g1rLcsGbIb5KwewBrBChaUu7sil8rWoeVU/k0EJd3SMN4VqNZw==", + "dependencies": { + "NETStandard.Library": "1.6.1", + "SharpDX": "4.2.0" + } + }, + "System.AppContext": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Collections": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Collections.Concurrent": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Console": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Diagnostics.Debug": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Diagnostics.Tools": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Diagnostics.Tracing": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Calendars": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Globalization.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0" + } + }, + "System.IO": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.Compression": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Buffers": "4.3.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.IO.Compression": "4.3.0" + } + }, + "System.IO.Compression.ZipFile": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", + "dependencies": { + "System.Buffers": "4.3.0", + "System.IO": "4.3.0", + "System.IO.Compression": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.IO.FileSystem": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.IO.FileSystem.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Linq": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Linq.Expressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Linq": "4.3.0", + "System.ObjectModel": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Emit.Lightweight": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Reflection.TypeExtensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Net.Http": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.DiagnosticSource": "4.3.0", + "System.Diagnostics.Tracing": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Extensions": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Security.Cryptography.X509Certificates": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Net.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Net.Sockets": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Net.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.ObjectModel": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Reflection": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", + "dependencies": { + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.ILGeneration": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Emit.Lightweight": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Emit.ILGeneration": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Resources.ResourceManager": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Globalization": "4.3.0", + "System.Reflection": "4.3.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0" + } + }, + "System.Runtime.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.Handles": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Runtime.InteropServices": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Reflection": "4.3.0", + "System.Reflection.Primitives": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Handles": "4.3.0" + } + }, + "System.Runtime.InteropServices.RuntimeInformation": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", + "dependencies": { + "System.Reflection": "4.3.0", + "System.Reflection.Extensions": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0" + } + }, + "System.Runtime.Numerics": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", + "dependencies": { + "System.Globalization": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0" + } + }, + "System.Security.Cryptography.Algorithms": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.Apple": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Cng": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Security.Cryptography.Csp": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0" + } + }, + "System.Security.Cryptography.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Collections.Concurrent": "4.3.0", + "System.Linq": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.OpenSsl": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", + "dependencies": { + "System.Collections": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Security.Cryptography.Primitives": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", + "dependencies": { + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Security.Cryptography.X509Certificates": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.Globalization.Calendars": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.Handles": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Runtime.Numerics": "4.3.0", + "System.Security.Cryptography.Algorithms": "4.3.0", + "System.Security.Cryptography.Cng": "4.3.0", + "System.Security.Cryptography.Csp": "4.3.0", + "System.Security.Cryptography.Encoding": "4.3.0", + "System.Security.Cryptography.OpenSsl": "4.3.0", + "System.Security.Cryptography.Primitives": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "runtime.native.System": "4.3.0", + "runtime.native.System.Net.Http": "4.3.0", + "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" + } + }, + "System.Text.Encoding": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Text.Encoding.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0", + "System.Text.Encoding": "4.3.0" + } + }, + "System.Text.RegularExpressions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", + "dependencies": { + "System.Runtime": "4.3.0" + } + }, + "System.Threading": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", + "dependencies": { + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Tasks": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Runtime": "4.3.0", + "System.Threading.Tasks": "4.3.0" + } + }, + "System.Threading.Timer": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0", + "Microsoft.NETCore.Targets": "1.1.0", + "System.Runtime": "4.3.0" + } + }, + "System.Xml.ReaderWriter": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.IO.FileSystem": "4.3.0", + "System.IO.FileSystem.Primitives": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Runtime.InteropServices": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Text.Encoding.Extensions": "4.3.0", + "System.Text.RegularExpressions": "4.3.0", + "System.Threading.Tasks": "4.3.0", + "System.Threading.Tasks.Extensions": "4.3.0" + } + }, + "System.Xml.XDocument": { + "type": "Transitive", + "resolved": "4.3.0", + "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", + "dependencies": { + "System.Collections": "4.3.0", + "System.Diagnostics.Debug": "4.3.0", + "System.Diagnostics.Tools": "4.3.0", + "System.Globalization": "4.3.0", + "System.IO": "4.3.0", + "System.Reflection": "4.3.0", + "System.Resources.ResourceManager": "4.3.0", + "System.Runtime": "4.3.0", + "System.Runtime.Extensions": "4.3.0", + "System.Text.Encoding": "4.3.0", + "System.Threading": "4.3.0", + "System.Xml.ReaderWriter": "4.3.0" + } + } + } + } +} \ No newline at end of file diff --git a/ffxiv_pictomancy b/ffxiv_pictomancy new file mode 160000 index 0000000..788bc33 --- /dev/null +++ b/ffxiv_pictomancy @@ -0,0 +1 @@ +Subproject commit 788bc339a67e7a3db01a47a954034a83b9c3b61b -- 2.49.1 From 1b2db4c698a4e0aba743ae2b76a92a5ea3f3c3d3 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 11 Dec 2025 05:38:35 +0100 Subject: [PATCH 084/140] Fixed pushes to imgui styles so its contained to only admin panel. --- LightlessSync/UI/SyncshellAdminUI.cs | 73 ++++++++++++++++------------ 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 66765d4..5830e1f 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -454,52 +454,63 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var style = ImGui.GetStyle(); - ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(10f, 5f) * ImGuiHelpers.GlobalScale); - ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8f, style.ItemSpacing.Y)); - ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 5f * ImGuiHelpers.GlobalScale); - var baseTab = UIColors.Get("FullBlack").WithAlpha(0.0f); var baseTabDim = UIColors.Get("FullBlack").WithAlpha(0.1f); var accent = UIColors.Get("LightlessPurple"); var accentHover = accent.WithAlpha(0.90f); var accentActive = accent; - ImGui.PushStyleColor(ImGuiCol.Tab, baseTab); - ImGui.PushStyleColor(ImGuiCol.TabHovered, accentHover); - ImGui.PushStyleColor(ImGuiCol.TabActive, accentActive); - ImGui.PushStyleColor(ImGuiCol.TabUnfocused, baseTabDim); - ImGui.PushStyleColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)); + //Pushing style vars for inner tab bar + ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, new Vector2(10f, 5f) * ImGuiHelpers.GlobalScale); + ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(8f, style.ItemSpacing.Y)); + ImGui.PushStyleVar(ImGuiStyleVar.TabRounding, 5f * ImGuiHelpers.GlobalScale); - using (var innerTabBar = ImRaii.TabBar("user_mgmt_inner_tab_" + GroupFullInfo.GID)) + try { - if (innerTabBar) + //Pushing color stack for inner tab bar + using (ImRaii.PushColor(ImGuiCol.Tab, baseTab)) + using (ImRaii.PushColor(ImGuiCol.TabHovered, accentHover)) + using (ImRaii.PushColor(ImGuiCol.TabActive, accentActive)) + using (ImRaii.PushColor(ImGuiCol.TabUnfocused, baseTabDim)) + using (ImRaii.PushColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f))) { - // Users tab - var usersTab = ImRaii.TabItem("Users"); - if (usersTab) + using (var innerTabBar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID)) { - DrawUserListSection(); - } - usersTab.Dispose(); + if (innerTabBar) + { + // Users tab + var usersTab = ImRaii.TabItem("Users"); + if (usersTab) + { + DrawUserListSection(); + } + usersTab.Dispose(); - // Cleanup tab - var cleanupTab = ImRaii.TabItem("Cleanup"); - if (cleanupTab) - { - DrawMassCleanupSection(); - } - cleanupTab.Dispose(); + // Cleanup tab + var cleanupTab = ImRaii.TabItem("Cleanup"); + if (cleanupTab) + { + DrawMassCleanupSection(); + } + cleanupTab.Dispose(); - // Bans tab - var bansTab = ImRaii.TabItem("Bans"); - if (bansTab) - { - DrawUserBansSection(); + // Bans tab + var bansTab = ImRaii.TabItem("Bans"); + if (bansTab) + { + DrawUserBansSection(); + } + bansTab.Dispose(); + } } - bansTab.Dispose(); } + mgmtTab.Dispose(); + } + finally + { + // Popping style vars (3) for inner tab bar + ImGui.PopStyleVar(3); } - mgmtTab.Dispose(); } private void DrawUserListSection() -- 2.49.1 From 09b78e18967a7a0db14fa85848efbc9529ef0c4d Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 11 Dec 2025 05:41:53 +0100 Subject: [PATCH 085/140] Fixed push color in main tab as well. --- LightlessSync/UI/SyncshellAdminUI.cs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 5830e1f..3db3682 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -211,24 +211,24 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var accentHover = accent.WithAlpha(0.90f); var accentActive = accent; - ImGui.PushStyleColor(ImGuiCol.Tab, baseTab); - ImGui.PushStyleColor(ImGuiCol.TabHovered, accentHover); - ImGui.PushStyleColor(ImGuiCol.TabActive, accentActive); - ImGui.PushStyleColor(ImGuiCol.TabUnfocused, baseTabDim); - ImGui.PushStyleColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f)); - - using (var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID)) + using (ImRaii.PushColor(ImGuiCol.Tab, baseTab)) + using (ImRaii.PushColor(ImGuiCol.TabHovered, accentHover)) + using (ImRaii.PushColor(ImGuiCol.TabActive, accentActive)) + using (ImRaii.PushColor(ImGuiCol.TabUnfocused, baseTabDim)) + using (ImRaii.PushColor(ImGuiCol.TabUnfocusedActive, accentActive.WithAlpha(0.80f))) { - if (tabbar) + using (var tabbar = ImRaii.TabBar("syncshell_tab_" + GroupFullInfo.GID)) { - DrawInvites(perm); - DrawManagement(); - DrawPermission(perm); - DrawProfile(); + if (tabbar) + { + DrawInvites(perm); + DrawManagement(); + DrawPermission(perm); + DrawProfile(); + } } } - ImGui.PopStyleColor(5); ImGui.PopStyleVar(3); ImGuiHelpers.ScaledDummy(2f); -- 2.49.1 From 0671c46e5db496def0a89d35833eacc03cfcc6e3 Mon Sep 17 00:00:00 2001 From: azyges Date: Thu, 11 Dec 2025 16:31:44 +0900 Subject: [PATCH 086/140] more tags meow --- LightlessSync/UI/EditProfileUi.Group.cs | 27 +----------- LightlessSync/UI/EditProfileUi.cs | 51 +++++----------------- LightlessSync/UI/Tags/ProfileTagService.cs | 47 +++++++++++++++++++- 3 files changed, 58 insertions(+), 67 deletions(-) diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index f93ba70..ee6a329 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -113,7 +113,7 @@ public partial class EditProfileUi using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); - if (ImGui.BeginChild("##GroupProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) + if (ImGui.BeginChild("##GroupProfileEditorCanvas", -Vector2.One, true)) { DrawGroupGuidelinesSection(scale); ImGui.Dummy(new Vector2(0f, 4f * scale)); @@ -236,31 +236,6 @@ public partial class EditProfileUi ImGui.EndChild(); ImGui.PopStyleVar(); - ImGui.Dummy(new Vector2(0f, 4f * scale)); - ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); - var savedTags = ProfileTagService.ResolveTags(_profileTagIds); - if (savedTags.Count == 0) - { - ImGui.TextDisabled("-- No tags set --"); - } - else - { - bool first = true; - for (int i = 0; i < savedTags.Count; i++) - { - if (!savedTags[i].HasContent) - continue; - - if (!first) - ImGui.SameLine(0f, 6f * scale); - first = false; - - using (ImRaii.PushId($"group-snapshot-tag-{i}")) - DrawTagPreview(savedTags[i], scale, "##groupSnapshotTagPreview"); - } - if (!first) - ImGui.NewLine(); - } } private void DrawGroupProfileImageControls() diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 4a5bd84..4682321 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -281,7 +281,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase using var panelBorder = ImRaii.PushColor(ImGuiCol.ChildBg, accentBorder); ImGui.PushStyleVar(ImGuiStyleVar.ChildRounding, 4f * scale); - if (ImGui.BeginChild("##ProfileEditorCanvas", -Vector2.One, true, ImGuiWindowFlags.NoScrollbar)) + if (ImGui.BeginChild("##ProfileEditorCanvas", -Vector2.One, true)) { DrawGuidelinesSection(scale); ImGui.Dummy(new Vector2(0f, 4f * scale)); @@ -432,30 +432,6 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase ImGui.PopStyleVar(); } - ImGui.Dummy(new Vector2(0f, 4f * scale)); - ImGui.TextColored(UIColors.Get("LightlessBlue"), "Saved Tags"); - var savedTags = ProfileTagService.ResolveTags(_profileTagIds); - if (savedTags.Count == 0) - { - ImGui.TextDisabled("-- No tags set --"); - } - else - { - bool first = true; - for (int i = 0; i < savedTags.Count; i++) - { - if (!savedTags[i].HasContent) - continue; - - if (!first) - ImGui.SameLine(0f, 6f * scale); - first = false; - using (ImRaii.PushId($"snapshot-tag-{i}")) - DrawTagPreview(savedTags[i], scale, "##snapshotTagPreview"); - } - if (!first) - ImGui.NewLine(); - } } private void DrawProfileImageControls(LightlessUserProfileData profile, float scale) @@ -943,17 +919,6 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase return _bannerImage.Length > 0 ? Convert.ToBase64String(_bannerImage) : null; } - private void DrawTagPreview(ProfileTagDefinition tag, float scale, string id) - { - var style = ImGui.GetStyle(); - var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); - var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); - - ImGui.InvisibleButton(id, tagSize); - var rectMin = ImGui.GetItemRectMin(); - var drawList = ImGui.GetWindowDrawList(); - ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); - } private static bool IsSupportedImageFormat(IImageFormat? format) { @@ -1150,12 +1115,18 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase var drawList = ImGui.GetWindowDrawList(); var textSize = ImGui.CalcTextSize(seString.TextValue); float minWidth = 160f * ImGuiHelpers.GlobalScale; - float bgWidth = Math.Max(textSize.X + 20f * ImGuiHelpers.GlobalScale, minWidth); float paddingY = 5f * ImGuiHelpers.GlobalScale; + float paddingX = 10f * ImGuiHelpers.GlobalScale; + float bgWidth = Math.Max(textSize.X + paddingX * 2f, minWidth); + + var style = ImGui.GetStyle(); + var fontHeight = monoFont.FontSize > 0f ? monoFont.FontSize : ImGui.GetFontSize(); + float frameHeight = fontHeight + style.FramePadding.Y * 2f; + float textBlockHeight = MathF.Max(frameHeight, textSize.Y); var cursor = ImGui.GetCursorScreenPos(); var rectMin = cursor; - var rectMax = rectMin + new Vector2(bgWidth, textSize.Y + paddingY * 2f); + var rectMax = rectMin + new Vector2(bgWidth, textBlockHeight + paddingY * 2f); float boost = Luminance.ComputeHighlight(previewTextColor, previewGlowColor); @@ -1166,8 +1137,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase drawList.AddRectFilled(rectMin, rectMax, ImGui.GetColorU32(bgColor), 5f); drawList.AddRect(rectMin, rectMax, ImGui.GetColorU32(borderColor), 5f, ImDrawFlags.None, 1.2f); - var textPos = new Vector2(rectMin.X + (bgWidth - textSize.X) * 0.5f, rectMin.Y + paddingY); - SeStringUtils.RenderSeStringWithHitbox(seString, textPos, monoFont); + var textOrigin = new Vector2(rectMin.X + (bgWidth - textSize.X) * 0.5f, rectMin.Y + paddingY); + SeStringUtils.RenderSeStringWithHitbox(seString, textOrigin, monoFont); ImGui.Dummy(new Vector2(0f, 1.5f)); } diff --git a/LightlessSync/UI/Tags/ProfileTagService.cs b/LightlessSync/UI/Tags/ProfileTagService.cs index 45f18dd..d340d76 100644 --- a/LightlessSync/UI/Tags/ProfileTagService.cs +++ b/LightlessSync/UI/Tags/ProfileTagService.cs @@ -119,7 +119,52 @@ public sealed class ProfileTagService [1006] = ProfileTagDefinition.FromIcon(61753), // Casual [1007] = ProfileTagDefinition.FromIcon(61754), // Hardcore [1008] = ProfileTagDefinition.FromIcon(61759), // Glamour - [1009] = ProfileTagDefinition.FromIcon(61760) // Mentor + [1009] = ProfileTagDefinition.FromIcon(61760), // Mentor + + // Role Tags + [2001] = ProfileTagDefinition.FromIconAndText(62581, "Tank"), + [2002] = ProfileTagDefinition.FromIconAndText(62582, "Healer"), + [2003] = ProfileTagDefinition.FromIconAndText(62583, "DPS"), + [2004] = ProfileTagDefinition.FromIconAndText(62584, "Melee DPS"), + [2005] = ProfileTagDefinition.FromIconAndText(62585, "Ranged DPS"), + [2006] = ProfileTagDefinition.FromIconAndText(62586, "Physical Ranged DPS"), + [2007] = ProfileTagDefinition.FromIconAndText(62587, "Magical Ranged DPS"), + + // Misc Role Tags + [2101] = ProfileTagDefinition.FromIconAndText(62146, "All-Rounder"), + + // Tank Job Tags + [2201] = ProfileTagDefinition.FromIconAndText(62119, "Paladin"), + [2202] = ProfileTagDefinition.FromIconAndText(62121, "Warrior"), + [2203] = ProfileTagDefinition.FromIconAndText(62132, "Dark Knight"), + [2204] = ProfileTagDefinition.FromIconAndText(62137, "Gunbreaker"), + + // Healer Job Tags + [2301] = ProfileTagDefinition.FromIconAndText(62124, "White Mage"), + [2302] = ProfileTagDefinition.FromIconAndText(62128, "Scholar"), + [2303] = ProfileTagDefinition.FromIconAndText(62133, "Astrologian"), + [2304] = ProfileTagDefinition.FromIconAndText(62140, "Sage"), + + // Melee DPS Job Tags + [2401] = ProfileTagDefinition.FromIconAndText(62120, "Monk"), + [2402] = ProfileTagDefinition.FromIconAndText(62122, "Dragoon"), + [2403] = ProfileTagDefinition.FromIconAndText(62130, "Ninja"), + [2404] = ProfileTagDefinition.FromIconAndText(62134, "Samurai"), + [2405] = ProfileTagDefinition.FromIconAndText(62139, "Reaper"), + [2406] = ProfileTagDefinition.FromIconAndText(62141, "Viper"), + + // PRanged DPS Job Tags + [2501] = ProfileTagDefinition.FromIconAndText(62123, "Bard"), + [2502] = ProfileTagDefinition.FromIconAndText(62131, "Machinist"), + [2503] = ProfileTagDefinition.FromIconAndText(62138, "Dancer"), + + // MRanged DPS Job Tags + [2601] = ProfileTagDefinition.FromIconAndText(62125, "Black Mage"), + [2602] = ProfileTagDefinition.FromIconAndText(62127, "Summoner"), + [2603] = ProfileTagDefinition.FromIconAndText(62135, "Red Mage"), + [2604] = ProfileTagDefinition.FromIconAndText(62142, "Pictomancer"), + [2605] = ProfileTagDefinition.FromIconAndText(62136, "Blue Mage") // this job sucks xd + }; } } -- 2.49.1 From 6395b1eb52a646f4a22a3f7087513ac3f2ed7c07 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 12 Dec 2025 06:06:50 +0100 Subject: [PATCH 087/140] Removal of use notifcation for downloads as its not used at all --- LightlessSync/UI/DownloadUi.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 37119e2..c54cf46 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -102,9 +102,9 @@ public class DownloadUi : WindowMediatorSubscriberBase var limiterSnapshot = _pairProcessingLimiter.GetSnapshot(); // Check if download notifications are enabled (not set to TextOverlay) - var useNotifications = _configService.Current.UseLightlessNotifications - ? _configService.Current.LightlessDownloadNotification != NotificationLocation.LightlessUi - : _configService.Current.UseNotificationsForDownloads; + var useNotifications = + _configService.Current.UseLightlessNotifications && + _configService.Current.LightlessDownloadNotification == NotificationLocation.LightlessUi; if (useNotifications) { -- 2.49.1 From 6891424b0de9dac5eeeb0657e46faeab60542b6c Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 14 Dec 2025 00:49:39 +0100 Subject: [PATCH 088/140] Fixed that notifcations prevents click around it. --- LightlessSync/UI/LightlessNotificationUI.cs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index b280350..9c4f8f5 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -159,29 +159,29 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase { var corner = _configService.Current.NotificationCorner; var offsetX = _configService.Current.NotificationOffsetX; + var offsetY = _configService.Current.NotificationOffsetY; var width = _configService.Current.NotificationWidth; - + float posX = corner == NotificationCorner.Left ? viewport.WorkPos.X + offsetX - _windowPaddingOffset : viewport.WorkPos.X + viewport.WorkSize.X - width - offsetX - _windowPaddingOffset; - - return new Vector2(posX, viewport.WorkPos.Y); + + float posY = viewport.WorkPos.Y + offsetY; + + return new Vector2(posX, posY); } private void DrawAllNotifications() { - var offsetY = _configService.Current.NotificationOffsetY; - var startY = ImGui.GetCursorPosY() + offsetY; - + var startY = ImGui.GetCursorPosY(); + for (int i = 0; i < _notifications.Count; i++) { var notification = _notifications[i]; - + if (_notificationYOffsets.TryGetValue(notification.Id, out var yOffset)) - { ImGui.SetCursorPosY(startY + yOffset); - } - + DrawNotification(notification); } } -- 2.49.1 From 44e91bef8fc485ae9da31ba789baf525b9bf83e6 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 14 Dec 2025 04:37:33 +0100 Subject: [PATCH 089/140] Cleaning of registry, fixed typo in object kind as it should be companion in the companion kind. --- .../PlayerData/Pairs/PairHandlerAdapter.cs | 3 +- .../PlayerData/Pairs/PairHandlerRegistry.cs | 47 +++++++------------ 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 925c42c..f170bac 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -1622,7 +1622,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (companion != nint.Zero) { await _ipcManager.CustomizePlus.RevertByIdAsync(customizeId).ConfigureAwait(false); - using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Pet, () => companion, isWatched: false).ConfigureAwait(false); + using GameObjectHandler tempHandler = await _gameObjectHandlerFactory.Create(ObjectKind.Companion, () => companion, isWatched: false).ConfigureAwait(false); await _ipcManager.Glamourer.RevertAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); await _ipcManager.Penumbra.RedrawAsync(Logger, tempHandler, applicationId, cancelToken).ConfigureAwait(false); } @@ -1848,5 +1848,4 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced); } - } diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index 5421baa..bf700e8 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -12,7 +12,7 @@ public sealed class PairHandlerRegistry : IDisposable private readonly object _gate = new(); private readonly object _pendingGate = new(); private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); - private readonly Dictionary _entriesByHandler = new(); + private readonly Dictionary _entriesByHandler = new(ReferenceEqualityComparer.Instance); private readonly IPairHandlerAdapterFactory _handlerFactory; private readonly PairManager _pairManager; @@ -162,7 +162,13 @@ public sealed class PairHandlerRegistry : IDisposable } } - handler.ApplyData(dto.CharaData); + if (!handler.Initialized) + { + handler.Initialize(); + QueuePendingCharacterData(registration, dto); + return PairOperationResult.Ok(); + } + return PairOperationResult.Ok(); } @@ -385,25 +391,20 @@ public sealed class PairHandlerRegistry : IDisposable private void QueuePendingCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) { - if (registration.CharacterIdent is null) - { - return; - } + if (registration.CharacterIdent is null) return; - CancellationTokenSource? previous = null; + CancellationTokenSource? previous; CancellationTokenSource cts; + lock (_pendingGate) { - if (_pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous)) - { - previous.Cancel(); - } + _pendingCharacterData.TryGetValue(registration.CharacterIdent, out previous); + previous?.Cancel(); cts = new CancellationTokenSource(); _pendingCharacterData[registration.CharacterIdent] = cts; } - previous?.Dispose(); cts.CancelAfter(_handlerReadyTimeout); _ = Task.Run(() => WaitThenApplyPendingCharacterDataAsync(registration, dto, cts.Token, cts)); } @@ -414,16 +415,10 @@ public sealed class PairHandlerRegistry : IDisposable lock (_pendingGate) { if (_pendingCharacterData.TryGetValue(ident, out cts)) - { _pendingCharacterData.Remove(ident); - } } - if (cts is not null) - { - cts.Cancel(); - cts.Dispose(); - } + cts?.Cancel(); } private void CancelAllPendingCharacterData() @@ -433,21 +428,13 @@ public sealed class PairHandlerRegistry : IDisposable { if (_pendingCharacterData.Count > 0) { - snapshot = _pendingCharacterData.Values.ToList(); + snapshot = [.. _pendingCharacterData.Values]; _pendingCharacterData.Clear(); } } - if (snapshot is null) - { - return; - } - - foreach (var cts in snapshot) - { - cts.Cancel(); - cts.Dispose(); - } + if (snapshot is null) return; + foreach (var cts in snapshot) cts.Cancel(); } private async Task WaitThenApplyPendingCharacterDataAsync( -- 2.49.1 From ee1fcb56614416b93afd053f4b481d4abf7be71d Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 15 Dec 2025 19:37:22 +0100 Subject: [PATCH 090/140] Fixed certain scenario that could break the event viewer --- .../Services/PerformanceCollectorService.cs | 26 +++++++++------ LightlessSync/UI/EventViewerUI.cs | 32 +++++++++++++++---- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/LightlessSync/Services/PerformanceCollectorService.cs b/LightlessSync/Services/PerformanceCollectorService.cs index d2b4c46..75fe736 100644 --- a/LightlessSync/Services/PerformanceCollectorService.cs +++ b/LightlessSync/Services/PerformanceCollectorService.cs @@ -26,12 +26,12 @@ public sealed class PerformanceCollectorService : IHostedService { if (!_lightlessConfigService.Current.LogPerformance) return func.Invoke(); - string cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage(); + var owner = sender.GetType().Name; + var counter = counterName.BuildMessage(); + var cn = string.Concat(owner, _counterSplit, counter); if (!PerformanceCounters.TryGetValue(cn, out var list)) - { list = PerformanceCounters[cn] = new(maxEntries); - } var dt = DateTime.UtcNow.Ticks; try @@ -53,12 +53,12 @@ public sealed class PerformanceCollectorService : IHostedService { if (!_lightlessConfigService.Current.LogPerformance) { act.Invoke(); return; } - var cn = sender.GetType().Name + _counterSplit + counterName.BuildMessage(); + var owner = sender.GetType().Name; + var counter = counterName.BuildMessage(); + var cn = string.Concat(owner, _counterSplit, counter); if (!PerformanceCounters.TryGetValue(cn, out var list)) - { list = PerformanceCounters[cn] = new(maxEntries); - } var dt = DateTime.UtcNow.Ticks; try @@ -72,7 +72,7 @@ public sealed class PerformanceCollectorService : IHostedService if (TimeSpan.FromTicks(elapsed) > TimeSpan.FromMilliseconds(10)) _logger.LogWarning(">10ms spike on {counterName}: {time}", cn, TimeSpan.FromTicks(elapsed)); #endif - list.Add(new(TimeOnly.FromDateTime(DateTime.Now), elapsed)); + list.Add((TimeOnly.FromDateTime(DateTime.Now), elapsed)); } } @@ -121,11 +121,11 @@ public sealed class PerformanceCollectorService : IHostedService sb.Append('|'); sb.Append("-Counter Name".PadRight(longestCounterName, '-')); sb.AppendLine(); - var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList(); - var previousCaller = orderedData[0].Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0]; + var orderedData = data.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase).ToList(); + var previousCaller = SplitCounterKey(orderedData[0].Key).Owner; foreach (var entry in orderedData) { - var newCaller = entry.Key.Split(_counterSplit, StringSplitOptions.RemoveEmptyEntries)[0]; + var newCaller = SplitCounterKey(entry.Key).Owner; if (!string.Equals(previousCaller, newCaller, StringComparison.Ordinal)) { DrawSeparator(sb, longestCounterName); @@ -157,6 +157,12 @@ public sealed class PerformanceCollectorService : IHostedService _logger.LogInformation("{perf}", sb.ToString()); } + private static (string Owner, string Counter) SplitCounterKey(string cn) + { + var parts = cn.Split(_counterSplit, 2, StringSplitOptions.None); + return (parts[0], parts.Length > 1 ? parts[1] : string.Empty); + } + private static void DrawSeparator(StringBuilder sb, int longestCounterName) { sb.Append("".PadRight(15, '-')); diff --git a/LightlessSync/UI/EventViewerUI.cs b/LightlessSync/UI/EventViewerUI.cs index 9ce1536..7afa1f7 100644 --- a/LightlessSync/UI/EventViewerUI.cs +++ b/LightlessSync/UI/EventViewerUI.cs @@ -205,17 +205,37 @@ internal class EventViewerUI : WindowMediatorSubscriberBase var posX = ImGui.GetCursorPosX(); var maxTextLength = ImGui.GetWindowContentRegionMax().X - posX; var textSize = ImGui.CalcTextSize(ev.Message).X; - var msg = ev.Message; - while (textSize > maxTextLength) + var msg = ev.Message ?? string.Empty; + + var maxEventTextLength = ImGui.GetContentRegionAvail().X; + + if (maxEventTextLength <= 0f) { - msg = msg[..^5] + "..."; - textSize = ImGui.CalcTextSize(msg).X; + ImGui.TextUnformatted(string.Empty); + return; } + + var eventTextSize = ImGui.CalcTextSize(msg).X; + + if (eventTextSize > maxEventTextLength) + { + const string ellipsis = "..."; + + while (eventTextSize > maxTextLength && msg.Length > ellipsis.Length) + { + var cut = Math.Min(5, msg.Length - ellipsis.Length); + msg = msg[..^cut] + ellipsis; + eventTextSize = ImGui.CalcTextSize(msg).X; + } + + if (textSize > maxEventTextLength) + msg = ellipsis; + } + ImGui.TextUnformatted(msg); + if (!string.Equals(msg, ev.Message, StringComparison.Ordinal)) - { UiSharedService.AttachToolTip(ev.Message); - } } } } -- 2.49.1 From eb11ff0b4c74831b83fad75badc1bea4575af250 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 15 Dec 2025 20:15:54 +0100 Subject: [PATCH 091/140] Fix some issues in syncshell admin panel, removed flag row and added it in user name. updated banned list to remove from imgui table. --- LightlessSync/UI/SyncshellAdminUI.cs | 331 +++++++++++++++++---------- 1 file changed, 214 insertions(+), 117 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index 3db3682..a60924c 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -14,6 +14,7 @@ using LightlessSync.Services.Profiles; using LightlessSync.UI.Services; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; +using SharpDX.DirectWrite; using SixLabors.ImageSharp; using System.Globalization; using System.Numerics; @@ -556,7 +557,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); ImGuiHelpers.ScaledDummy(2f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.0f); ImGuiHelpers.ScaledDummy(2f); if (_uiSharedService.IconTextButton(FontAwesomeIcon.Unlink, "Check for Inactive Users")) @@ -621,7 +622,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } ImGuiHelpers.ScaledDummy(4f); - UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); + UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.0f); ImGuiHelpers.ScaledDummy(2f); DrawAutoPruneSettings(); @@ -636,50 +637,28 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase { _bannedUsers = _apiController.GroupGetBannedUsers(new GroupDto(GroupFullInfo.Group)).Result; } + ImGuiHelpers.ScaledDummy(2f); - var tableFlags = ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp; - if (_bannedUsers.Count > 10) - tableFlags |= ImGuiTableFlags.ScrollY; + ImGui.BeginChild("bannedListScroll#" + GroupFullInfo.GID, new Vector2(0, 0), true); - if (ImGui.BeginTable("bannedusertable" + GroupFullInfo.GID, 6, tableFlags)) + var style = ImGui.GetStyle(); + float fullW = ImGui.GetContentRegionAvail().X; + + float colIdentity = fullW * 0.45f; + float colMeta = fullW * 0.35f; + float colActions = fullW - colIdentity - colMeta - style.ItemSpacing.X * 2.0f; + + // Header + DrawBannedListHeader(colIdentity, colMeta); + + int rowIndex = 0; + foreach (var bannedUser in _bannedUsers.ToList()) { - ImGui.TableSetupColumn("UID", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("Alias", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("By", ImGuiTableColumnFlags.None, 1); - ImGui.TableSetupColumn("Date", ImGuiTableColumnFlags.None, 2); - ImGui.TableSetupColumn("Reason", ImGuiTableColumnFlags.None, 3); - ImGui.TableSetupColumn("Actions", ImGuiTableColumnFlags.None, 1); - - ImGui.TableHeadersRow(); - - foreach (var bannedUser in _bannedUsers.ToList()) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.UID); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.UserAlias ?? string.Empty); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.BannedBy); - - ImGui.TableNextColumn(); - ImGui.TextUnformatted(bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture)); - - ImGui.TableNextColumn(); - UiSharedService.TextWrapped(bannedUser.Reason); - - ImGui.TableNextColumn(); - using var _ = ImRaii.PushId(bannedUser.UID); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) - { - _apiController.GroupUnbanUser(bannedUser); - _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); - } - } - - ImGui.EndTable(); + // Each row + DrawBannedRow(bannedUser, rowIndex++, colIdentity, colMeta, colActions); } + + ImGui.EndChild(); } private void DrawInvites(GroupPermissions perm) @@ -729,7 +708,7 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase private void DrawUserListCustom(IReadOnlyList pairs, GroupFullInfoDto GroupFullInfo) { - // Search bar (unchanged) + // Search bar ImGui.PushItemWidth(0); _uiSharedService.IconText(FontAwesomeIcon.Search, UIColors.Get("LightlessPurple")); ImGui.SameLine(); @@ -768,78 +747,105 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase .ThenBy(p => p.Key.GetNote() ?? p.Key.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase) .ToList(); + DrawUserListHeader(); + ImGui.BeginChild("userListScroll#" + GroupFullInfo.Group.AliasOrGID, new Vector2(0, 0), true); - var style = ImGui.GetStyle(); - float fullW = ImGui.GetContentRegionAvail().X; - - float colUid = fullW * 0.50f; - float colFlags = fullW * 0.10f; - float colActions = fullW - colUid - colFlags - style.ItemSpacing.X * 2.0f; - - DrawUserListHeader(colUid, colFlags); - int rowIndex = 0; foreach (var kv in orderedPairs) { var pair = kv.Key; var userInfoOpt = kv.Value; - DrawUserRowCustom(pair, userInfoOpt, GroupFullInfo, rowIndex++, colUid, colFlags, colActions); + DrawUserRowCustom(pair, userInfoOpt, GroupFullInfo, rowIndex++); } ImGui.EndChild(); } - private static void DrawUserListHeader(float colUid, float colFlags) + private static void DrawUserListHeader() { var style = ImGui.GetStyle(); float x0 = ImGui.GetCursorPosX(); + float fullW = ImGui.GetContentRegionAvail().X; ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessPurple")); - // Alias / UID / Note ImGui.SetCursorPosX(x0); - ImGui.TextUnformatted("Alias / UID / Note"); + ImGui.TextUnformatted("User"); - // Flags - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colUid + style.ItemSpacing.X); - ImGui.TextUnformatted("Flags"); + const string actionsLabel = "Actions"; + float labelWidth = ImGui.CalcTextSize(actionsLabel).X; - // Actions ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.0f); - ImGui.TextUnformatted("Actions"); + ImGui.SetCursorPosX(x0 + fullW - labelWidth); + ImGui.TextUnformatted(actionsLabel); ImGui.PopStyleColor(); UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.0f); } - private void DrawUserRowCustom(Pair pair, GroupPairUserInfo? userInfoOpt, GroupFullInfoDto GroupFullInfo, int rowIndex, float colUid, float colFlags, float colActions) + private void DrawUserRowCustom(Pair pair, GroupPairUserInfo? userInfoOpt, GroupFullInfoDto GroupFullInfo, int rowIndex) { using var id = ImRaii.PushId("userRow_" + pair.UserData.UID); var style = ImGui.GetStyle(); - float x0 = ImGui.GetCursorPosX(); + Vector2 rowStart = ImGui.GetCursorPos(); + Vector2 rowStartScr = ImGui.GetCursorScreenPos(); + float fullW = ImGui.GetContentRegionAvail().X; + + float frameH = ImGui.GetFrameHeight(); + float textH = ImGui.GetTextLineHeight(); + float rowHeight = frameH; if (rowIndex % 2 == 0) { var drawList = ImGui.GetWindowDrawList(); - var pMin = ImGui.GetCursorScreenPos(); - var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.5f; - var pMax = new Vector2(pMin.X + colUid + colFlags + colActions + style.ItemSpacing.X * 2.0f, - pMin.Y + rowHeight); + var pMin = rowStartScr; + var pMax = new Vector2(pMin.X + fullW, pMin.Y + rowHeight); - var bgColor = UIColors.Get("FullBlack") with { W = 0.0f }; + var bgColor = UIColors.Get("FullBlack").WithAlpha(0.05f); drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); } var isUserOwner = string.Equals(pair.UserData.UID, GroupFullInfo.OwnerUID, StringComparison.Ordinal); var userInfo = userInfoOpt ?? GroupPairUserInfo.None; - ImGui.SetCursorPosX(x0); - ImGui.AlignTextToFramePadding(); + float baselineY = rowStart.Y + (rowHeight - textH) / 2f; + + ImGui.SetCursorPos(new Vector2(rowStart.X, baselineY)); + + bool hasFlag = false; + if (userInfoOpt != null && (userInfo.IsModerator() || userInfo.IsPinned() || isUserOwner)) + { + if (userInfo.IsModerator()) + { + _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); + UiSharedService.AttachToolTip("Moderator"); + hasFlag = true; + } + + if (userInfo.IsPinned() && !isUserOwner) + { + if (hasFlag) ImGui.SameLine(0f, style.ItemSpacing.X); + _uiSharedService.IconText(FontAwesomeIcon.Thumbtack); + UiSharedService.AttachToolTip("Pinned"); + hasFlag = true; + } + + if (isUserOwner) + { + if (hasFlag) ImGui.SameLine(0f, style.ItemSpacing.X); + _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("Owner"); + hasFlag = true; + } + } + + if (hasFlag) + ImGui.SameLine(0f, style.ItemSpacing.X); + + ImGui.SetCursorPosY(baselineY); var note = pair.GetNote(); var text = note == null @@ -848,55 +854,47 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline); UiSharedService.ColorText(text, boolcolor); + if (ImGui.IsItemClicked()) - { ImGui.SetClipboardText(text); - } if (!string.IsNullOrEmpty(pair.PlayerName)) - { UiSharedService.AttachToolTip(pair.PlayerName); - } - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colUid + style.ItemSpacing.X); + DrawUserActions(pair, GroupFullInfo, userInfo, isUserOwner, baselineY); - if (userInfoOpt != null && (userInfo.IsModerator() || userInfo.IsPinned() || isUserOwner)) - { - if (userInfo.IsModerator()) - { - _uiSharedService.IconText(FontAwesomeIcon.UserShield, UIColors.Get("LightlessPurple")); - UiSharedService.AttachToolTip("Moderator"); - } - if (userInfo.IsPinned() && !isUserOwner) - { - _uiSharedService.IconText(FontAwesomeIcon.Thumbtack); - UiSharedService.AttachToolTip("Pinned"); - } - if (isUserOwner) - { - _uiSharedService.IconText(FontAwesomeIcon.Crown, UIColors.Get("LightlessYellow")); - UiSharedService.AttachToolTip("Owner"); - } - } - else - { - _uiSharedService.IconText(FontAwesomeIcon.None); - } - - ImGui.SameLine(); - ImGui.SetCursorPosX(x0 + colUid + colFlags + style.ItemSpacing.X * 2.0f); - - DrawUserActions(pair, GroupFullInfo, userInfo, isUserOwner); - - ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + // Move cursor to next row + ImGui.SetCursorPos(new Vector2(rowStart.X, rowStart.Y + rowHeight + style.ItemSpacing.Y)); } - private void DrawUserActions(Pair pair, GroupFullInfoDto GroupFullInfo, GroupPairUserInfo userInfo, bool isUserOwner) + private void DrawUserActions(Pair pair, GroupFullInfoDto GroupFullInfo, GroupPairUserInfo userInfo, bool isUserOwner, float baselineY) { + var style = ImGui.GetStyle(); + float frameH = ImGui.GetFrameHeight(); + + int buttonCount = 0; + if (_isOwner) + buttonCount += 2; // Crown + Mod + if (userInfo == GroupPairUserInfo.None || (!userInfo.IsModerator() && !isUserOwner)) + buttonCount += 3; // Pin + Trash + Ban + + if (buttonCount == 0) + return; + + float totalWidth = buttonCount * frameH + (buttonCount - 1) * style.ItemSpacing.X; + + float curX = ImGui.GetCursorPosX(); + float avail = ImGui.GetContentRegionAvail().X; + + float startX = curX + MathF.Max(0, avail - (totalWidth + 30f)); + + ImGui.SetCursorPos(new Vector2(startX, baselineY)); + + bool first = true; + if (_isOwner) { - // Transfer ownership to user + // Transfer ownership using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessYellow"))) using (ImRaii.Disabled(!UiSharedService.ShiftPressed())) { @@ -910,12 +908,15 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.AttachToolTip("Hold SHIFT and click to transfer ownership of this Syncshell to " + pair.UserData.AliasOrUID + Environment.NewLine + "WARNING: This action is irreversible and will close screen."); - ImGui.SameLine(); - // Mod / Demod user + first = false; + + // Mod / Demod using (ImRaii.PushColor(ImGuiCol.Text, userInfo.IsModerator() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) { + if (!first) ImGui.SameLine(0f, style.ItemSpacing.X); + if (_uiSharedService.IconButton(FontAwesomeIcon.UserShield)) { userInfo.SetModerator(!userInfo.IsModerator()); @@ -926,15 +927,17 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.AttachToolTip( userInfo.IsModerator() ? $"Demod {pair.UserData.AliasOrUID}" : $"Mod {pair.UserData.AliasOrUID}"); - ImGui.SameLine(); + first = false; } if (userInfo == GroupPairUserInfo.None || (!userInfo.IsModerator() && !isUserOwner)) { - // Pin user + // Pin using (ImRaii.PushColor(ImGuiCol.Text, userInfo.IsPinned() ? UIColors.Get("DimRed") : UIColors.Get("PairBlue"))) { + if (!first) ImGui.SameLine(0f, style.ItemSpacing.X); + if (_uiSharedService.IconButton(FontAwesomeIcon.Thumbtack)) { userInfo.SetPinned(!userInfo.IsPinned()); @@ -946,12 +949,14 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.AttachToolTip( userInfo.IsPinned() ? $"Unpin {pair.UserData.AliasOrUID}" : $"Pin {pair.UserData.AliasOrUID}"); - ImGui.SameLine(); + first = false; - // Remove user + // Trash using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) { + if (!first) ImGui.SameLine(0f, style.ItemSpacing.X); + if (_uiSharedService.IconButton(FontAwesomeIcon.Trash)) { _ = _apiController.GroupRemoveUser(new GroupPairDto(GroupFullInfo.Group, pair.UserData)); @@ -960,12 +965,14 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase UiSharedService.AttachToolTip($"Remove {pair.UserData.AliasOrUID} from Syncshell" + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); - ImGui.SameLine(); + first = false; - // Ban user + // Ban using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("DimRed"))) using (ImRaii.Disabled(!UiSharedService.CtrlPressed())) { + if (!first) ImGui.SameLine(0f, style.ItemSpacing.X); + if (_uiSharedService.IconButton(FontAwesomeIcon.Ban)) { Mediator.Publish(new OpenBanUserPopupMessage(pair, GroupFullInfo)); @@ -977,6 +984,96 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase } } + private static void DrawBannedListHeader(float colIdentity, float colMeta) + { + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + + // User and reason + ImGui.SetCursorPosX(x0); + ImGui.TextUnformatted("User / Reason"); + + // Moderator and Date + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); + ImGui.TextUnformatted("Moderator / Date"); + + // Actions + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f); + ImGui.TextUnformatted("Actions"); + + ImGui.PopStyleColor(); + + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.0f); + } + + private void DrawBannedRow(BannedGroupUserDto bannedUser, int rowIndex, float colIdentity, float colMeta, float colActions) + { + using var id = ImRaii.PushId("banRow_" + bannedUser.UID); + + var style = ImGui.GetStyle(); + float x0 = ImGui.GetCursorPosX(); + + if (rowIndex % 2 == 0) + { + var drawList = ImGui.GetWindowDrawList(); + var pMin = ImGui.GetCursorScreenPos(); + var rowHeight = ImGui.GetTextLineHeightWithSpacing() * 2.6f; + var pMax = new Vector2( + pMin.X + colIdentity + colMeta + colActions + style.ItemSpacing.X * 2.0f, + pMin.Y + rowHeight); + + var bgColor = UIColors.Get("FullBlack").WithAlpha(0.10f); + drawList.AddRectFilled(pMin, pMax, ImGui.ColorConvertFloat4ToU32(bgColor)); + } + + ImGui.SetCursorPosX(x0); + ImGui.AlignTextToFramePadding(); + + string alias = bannedUser.UserAlias ?? string.Empty; + string line1 = string.IsNullOrEmpty(alias) + ? bannedUser.UID + : $"{alias} ({bannedUser.UID})"; + + ImGui.TextUnformatted(line1); + + var reason = bannedUser.Reason ?? string.Empty; + if (!string.IsNullOrWhiteSpace(reason)) + { + var reasonPos = new Vector2(x0, ImGui.GetCursorPosY()); + ImGui.SetCursorPos(reasonPos); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + UiSharedService.TextWrapped(reason); + ImGui.PopStyleColor(); + } + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + style.ItemSpacing.X); + + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted($"By: {bannedUser.BannedBy}"); + + var dateText = bannedUser.BannedOn.ToLocalTime().ToString(CultureInfo.CurrentCulture); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey); + ImGui.TextUnformatted(dateText); + ImGui.PopStyleColor(); + + ImGui.SameLine(); + ImGui.SetCursorPosX(x0 + colIdentity + colMeta + style.ItemSpacing.X * 2.0f); + + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Check, "Unban")) + { + _apiController.GroupUnbanUser(bannedUser); + _bannedUsers.RemoveAll(b => string.Equals(b.UID, bannedUser.UID, StringComparison.Ordinal)); + } + + UiSharedService.AttachToolTip($"Unban {alias} ({bannedUser.UID}) from this Syncshell"); + + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } private void SavePruneSettings() { if (_autoPruneDays <= 0) -- 2.49.1 From bdfcf254a8387477becafb0701bf1bba3e55cdd7 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 15 Dec 2025 20:22:31 +0100 Subject: [PATCH 092/140] Added copy text in tooltip of uid --- LightlessSync/UI/SyncshellAdminUI.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LightlessSync/UI/SyncshellAdminUI.cs b/LightlessSync/UI/SyncshellAdminUI.cs index a60924c..730d124 100644 --- a/LightlessSync/UI/SyncshellAdminUI.cs +++ b/LightlessSync/UI/SyncshellAdminUI.cs @@ -854,12 +854,12 @@ public class SyncshellAdminUI : WindowMediatorSubscriberBase var boolcolor = UiSharedService.GetBoolColor(pair.IsOnline); UiSharedService.ColorText(text, boolcolor); - + if (ImGui.IsItemClicked()) - ImGui.SetClipboardText(text); + ImGui.SetClipboardText(pair.UserData.AliasOrUID); if (!string.IsNullOrEmpty(pair.PlayerName)) - UiSharedService.AttachToolTip(pair.PlayerName); + UiSharedService.AttachToolTip(pair.PlayerName + $"{Environment.NewLine}Click to copy UID or Alias"); DrawUserActions(pair, GroupFullInfo, userInfo, isUserOwner, baselineY); -- 2.49.1 From 4444a88746d7f763335c6465f14946c3d923a16a Mon Sep 17 00:00:00 2001 From: azyges Date: Tue, 16 Dec 2025 06:31:29 +0900 Subject: [PATCH 093/140] watafak --- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 9 + LightlessSync/PlayerData/Pairs/Pair.cs | 24 + .../PlayerData/Pairs/PairDebugInfo.cs | 32 ++ .../PlayerData/Pairs/PairHandlerAdapter.cs | 132 +++-- .../PlayerData/Pairs/PairHandlerRegistry.cs | 5 + .../ActorTracking/ActorObjectService.cs | 24 +- LightlessSync/Services/CharacterAnalyzer.cs | 48 +- LightlessSync/Services/DalamudUtilService.cs | 123 ++++- LightlessSync/Services/Events/Event.cs | 8 +- .../TextureCompressionCapabilities.cs | 10 +- .../TextureDownscaleService.cs | 31 ++ .../TextureMetadataHelper.cs | 136 +++-- LightlessSync/Services/UiFactory.cs | 98 ++-- LightlessSync/UI/CompactUI.cs | 130 +---- LightlessSync/UI/CreateSyncshellUI.cs | 12 +- LightlessSync/UI/DataAnalysisUi.cs | 28 +- LightlessSync/UI/EditProfileUi.cs | 30 +- LightlessSync/UI/EventViewerUI.cs | 9 +- LightlessSync/UI/IntroUI.cs | 9 +- LightlessSync/UI/LightFinderUI.cs | 9 +- LightlessSync/UI/PermissionWindowUI.cs | 11 +- LightlessSync/UI/SettingsUi.cs | 483 ++++++++++++++++-- LightlessSync/UI/StandaloneProfileUi.cs | 49 +- LightlessSync/UI/SyncshellFinderUI.cs | 8 +- LightlessSync/UI/TopTabMenu.cs | 31 +- LightlessSync/UI/UIColors.cs | 1 + LightlessSync/UI/UpdateNotesUi.cs | 14 +- LightlessSync/UI/ZoneChatUi.cs | 19 +- LightlessSync/Utils/WindowUtils.cs | 139 +++++ OtterGui | 2 +- Penumbra.Api | 2 +- Penumbra.String | 2 +- 32 files changed, 1204 insertions(+), 464 deletions(-) create mode 100644 LightlessSync/PlayerData/Pairs/PairDebugInfo.cs create mode 100644 LightlessSync/Utils/WindowUtils.cs diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index c89d311..a7bd80c 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -16,6 +16,15 @@ public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject new string? PlayerName { get; } string PlayerNameHash { get; } uint PlayerCharacterId { get; } + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } void Initialize(); void ApplyData(CharacterData data); diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 0eda06a..7d780dd 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -189,4 +189,28 @@ public class Pair handler.SetUploading(true); } + + public PairDebugInfo GetDebugInfo() + { + var handler = TryGetHandler(); + if (handler is null) + { + return PairDebugInfo.Empty; + } + + return new PairDebugInfo( + true, + handler.Initialized, + handler.IsVisible, + handler.ScheduledForDeletion, + handler.LastDataReceivedAt, + handler.LastApplyAttemptAt, + handler.LastSuccessfulApplyAt, + handler.LastFailureReason, + handler.LastBlockingConditions, + handler.IsApplying, + handler.IsDownloading, + handler.PendingDownloadCount, + handler.ForbiddenDownloadCount); + } } diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs new file mode 100644 index 0000000..9074c82 --- /dev/null +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -0,0 +1,32 @@ +namespace LightlessSync.PlayerData.Pairs; + +public sealed record PairDebugInfo( + bool HasHandler, + bool HandlerInitialized, + bool HandlerVisible, + bool HandlerScheduledForDeletion, + DateTime? LastDataReceivedAt, + DateTime? LastApplyAttemptAt, + DateTime? LastSuccessfulApplyAt, + string? LastFailureReason, + IReadOnlyList BlockingConditions, + bool IsApplying, + bool IsDownloading, + int PendingDownloadCount, + int ForbiddenDownloadCount) +{ + public static PairDebugInfo Empty { get; } = new( + false, + false, + false, + false, + null, + null, + null, + null, + Array.Empty(), + false, + false, + 0, + 0); +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index f170bac..556dd84 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -65,6 +65,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly object _pauseLock = new(); private Task _pauseTransitionTask = Task.CompletedTask; private bool _pauseRequested; + private DateTime? _lastDataReceivedAt; + private DateTime? _lastApplyAttemptAt; + private DateTime? _lastSuccessfulApplyAt; + private string? _lastFailureReason; + private IReadOnlyList _lastBlockingConditions = Array.Empty(); public string Ident { get; } public bool Initialized { get; private set; } @@ -101,6 +106,15 @@ 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 DateTime? LastDataReceivedAt => _lastDataReceivedAt; + public DateTime? LastApplyAttemptAt => _lastApplyAttemptAt; + public DateTime? LastSuccessfulApplyAt => _lastSuccessfulApplyAt; + public string? LastFailureReason => _lastFailureReason; + public IReadOnlyList LastBlockingConditions => _lastBlockingConditions; + public bool IsApplying => _applicationTask is { IsCompleted: false }; + public bool IsDownloading => _downloadManager.IsDownloading; + public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count; + public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count; public PairHandlerAdapter( ILogger logger, @@ -423,6 +437,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa { EnsureInitialized(); LastReceivedCharacterData = data; + _lastDataReceivedAt = DateTime.UtcNow; ApplyLastReceivedData(); } @@ -713,10 +728,26 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa && _ipcManager.Glamourer.APIAvailable; } + private void RecordFailure(string reason, params string[] conditions) + { + _lastFailureReason = reason; + _lastBlockingConditions = conditions.Length == 0 ? Array.Empty() : conditions.ToArray(); + } + + private void ClearFailureState() + { + _lastFailureReason = null; + _lastBlockingConditions = Array.Empty(); + } + public void ApplyCharacterData(Guid applicationBase, CharacterData characterData, bool forceApplyCustomization = false) { + _lastApplyAttemptAt = DateTime.UtcNow; + ClearFailureState(); + if (characterData is null) { + RecordFailure("Received null character data", "InvalidData"); Logger.LogWarning("[BASE-{appBase}] Received null character data, skipping application for {handler}", applicationBase, GetLogIdentifier()); SetUploading(false); return; @@ -725,9 +756,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa var user = GetPrimaryUserData(); 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, - "Cannot apply character data: you are in combat, deferring application"))); + reason))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in combat", applicationBase); + RecordFailure(reason, "Combat"); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(false); return; @@ -735,9 +768,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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, - "Cannot apply character data: you are performing music, deferring application"))); + reason))); Logger.LogDebug("[BASE-{appBase}] Received data but player is performing", applicationBase); + RecordFailure(reason, "Performance"); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(false); return; @@ -745,9 +780,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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, - "Cannot apply character data: you are in an instance, deferring application"))); + reason))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in instance", applicationBase); + RecordFailure(reason, "Instance"); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(false); return; @@ -755,9 +792,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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, - "Cannot apply character data: you are in a cutscene, deferring application"))); + reason))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in a cutscene", applicationBase); + RecordFailure(reason, "Cutscene"); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(false); return; @@ -765,9 +804,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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, - "Cannot apply character data: you are in GPose, deferring application"))); + reason))); Logger.LogDebug("[BASE-{appBase}] Received data but player is in GPose", applicationBase); + RecordFailure(reason, "GPose"); _dataReceivedInDowntime = new(applicationBase, characterData, forceApplyCustomization); SetUploading(false); return; @@ -775,9 +816,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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, - "Cannot apply character data: Penumbra or Glamourer is not available, deferring application"))); + 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); return; @@ -1260,6 +1303,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, toDownloadFiles)) { + RecordFailure("Auto pause triggered by VRAM usage thresholds", "VRAMThreshold"); _downloadManager.ClearDownload(); return; } @@ -1272,9 +1316,24 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (downloadToken.IsCancellationRequested) { Logger.LogTrace("[BASE-{appBase}] Detected cancellation", applicationBase); + RecordFailure("Download cancelled", "Cancellation"); return; } + if (!skipDownscaleForPair) + { + var downloadedTextureHashes = toDownloadReplacements + .Where(static replacement => replacement.GamePaths.Any(static path => path.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) + .Select(static replacement => replacement.Hash) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (downloadedTextureHashes.Count > 0) + { + await _textureDownscaleService.WaitForPendingJobsAsync(downloadedTextureHashes, downloadToken).ConfigureAwait(false); + } + } + toDownloadReplacements = TryCalculateModdedDictionary(applicationBase, charaData, out moddedPaths, downloadToken); if (toDownloadReplacements.TrueForAll(c => _downloadManager.ForbiddenTransfers.Exists(f => string.Equals(f.Hash, c.Hash, StringComparison.Ordinal)))) @@ -1287,6 +1346,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (!await _playerPerformanceService.CheckBothThresholds(this, charaData).ConfigureAwait(false)) { + RecordFailure("Auto pause triggered by performance thresholds", "PerformanceThreshold"); return; } } @@ -1307,6 +1367,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = true; + RecordFailure("Handler not available for application", "HandlerUnavailable"); return; } @@ -1322,6 +1383,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (downloadToken.IsCancellationRequested || (appToken?.IsCancellationRequested ?? false)) { _forceFullReapply = true; + RecordFailure("Application cancelled", "Cancellation"); return; } @@ -1359,6 +1421,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = true; + RecordFailure("Penumbra collection unavailable", "PenumbraUnavailable"); return; } } @@ -1378,6 +1441,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = true; + RecordFailure("Game object not available for application", "GameObjectUnavailable"); return; } @@ -1414,41 +1478,45 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _needsCollectionRebuild = false; if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) { - _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); - } - if (LastAppliedDataTris < 0) - { - await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); - } - - StorePerformanceMetrics(charaData); - Logger.LogDebug("[{applicationId}] Application finished", _applicationId); + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, charaData, new List()); } - catch (OperationCanceledException) + if (LastAppliedDataTris < 0) { - Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + await _playerPerformanceService.CheckTriangleUsageThresholds(this, charaData).ConfigureAwait(false); + } + + StorePerformanceMetrics(charaData); + _lastSuccessfulApplyAt = DateTime.UtcNow; + ClearFailureState(); + Logger.LogDebug("[{applicationId}] Application finished", _applicationId); + } + catch (OperationCanceledException) + { + Logger.LogDebug("[{applicationId}] Application cancelled for {handler}", _applicationId, GetLogIdentifier()); + _cachedData = charaData; + _pairStateCache.Store(Ident, charaData); + _forceFullReapply = true; + RecordFailure("Application cancelled", "Cancellation"); + } + catch (Exception ex) + { + if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) + { + IsVisible = false; + _forceApplyMods = true; _cachedData = charaData; _pairStateCache.Store(Ident, charaData); _forceFullReapply = true; + Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); } - catch (Exception ex) + else { - if (ex is AggregateException aggr && aggr.InnerExceptions.Any(e => e is ArgumentNullException)) - { - IsVisible = false; - _forceApplyMods = true; - _cachedData = charaData; - _pairStateCache.Store(Ident, charaData); - _forceFullReapply = true; - Logger.LogDebug("[{applicationId}] Cancelled, player turned null during application", _applicationId); - } - else - { - Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); - _forceFullReapply = true; - } + Logger.LogWarning(ex, "[{applicationId}] Cancelled", _applicationId); + _forceFullReapply = true; } + RecordFailure($"Application failed: {ex.Message}", "Exception"); } +} private void FrameworkUpdate() { diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index bf700e8..f490804 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -69,6 +69,10 @@ public sealed class PairHandlerRegistry : IDisposable handler = entry.Handler; handler.ScheduledForDeletion = false; entry.AddPair(registration.PairIdent); + if (!handler.Initialized) + { + handler.Initialize(); + } } ApplyPauseStateForHandler(handler); @@ -169,6 +173,7 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Ok(); } + handler.ApplyData(dto.CharaData); return PairOperationResult.Ok(); } diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index c2650ad..1813947 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -230,7 +230,12 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (descriptor.ObjectKind == DalamudObjectKind.Player && !string.IsNullOrEmpty(descriptor.HashedContentId)) { - var liveHash = DalamudUtilService.GetHashedCIDFromPlayerPointer(descriptor.Address); + if (!TryGetLivePlayerHash(descriptor, out var liveHash)) + { + UntrackGameObject(descriptor.Address); + return false; + } + if (!string.Equals(liveHash, descriptor.HashedContentId, StringComparison.Ordinal)) { UntrackGameObject(descriptor.Address); @@ -241,6 +246,16 @@ public sealed class ActorObjectService : IHostedService, IDisposable return true; } + private bool TryGetLivePlayerHash(ActorDescriptor descriptor, out string liveHash) + { + liveHash = string.Empty; + + if (_objectTable.CreateObjectReference(descriptor.Address) is not IPlayerCharacter playerCharacter) + return false; + + return DalamudUtilService.TryGetHashedCID(playerCharacter, out liveHash); + } + public void RefreshTrackedActors(bool force = false) { var now = DateTime.UtcNow; @@ -425,8 +440,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable bool isLocal = _objectTable.LocalPlayer?.Address == address; string hashedCid = string.Empty; + IPlayerCharacter? resolvedPlayer = null; if (_objectTable.CreateObjectReference(address) is IPlayerCharacter playerCharacter) { + resolvedPlayer = playerCharacter; name = playerCharacter.Name.TextValue ?? string.Empty; objectIndex = playerCharacter.ObjectIndex; isInGpose = objectIndex >= 200; @@ -439,7 +456,10 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (objectKind == DalamudObjectKind.Player) { - hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); + if (resolvedPlayer == null || !DalamudUtilService.TryGetHashedCID(resolvedPlayer, out hashedCid)) + { + hashedCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(address); + } } var (ownedKind, ownerEntityId) = DetermineOwnedKind(gameObject, objectKind, isLocal); diff --git a/LightlessSync/Services/CharacterAnalyzer.cs b/LightlessSync/Services/CharacterAnalyzer.cs index 2a0aa04..3eebced 100644 --- a/LightlessSync/Services/CharacterAnalyzer.cs +++ b/LightlessSync/Services/CharacterAnalyzer.cs @@ -250,32 +250,40 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } OriginalSize = normalSize; CompressedSize = compressedsize.Item2.LongLength; + RefreshFormat(); } public long OriginalSize { get; private set; } = OriginalSize; public long CompressedSize { get; private set; } = CompressedSize; public long Triangles { get; private set; } = Triangles; - public Lazy Format = new(() => + public Lazy Format => _format ??= CreateFormatValue(); + + private Lazy? _format; + + public void RefreshFormat() { - switch (FileType) + _format = CreateFormatValue(); + } + + private Lazy CreateFormatValue() + => new(() => { - case "tex": - { - try - { - using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read); - using var reader = new BinaryReader(stream); - reader.BaseStream.Position = 4; - var format = (TexFile.TextureFormat)reader.ReadInt32(); - return format.ToString(); - } - catch - { - return "Unknown"; - } - } - default: + if (!string.Equals(FileType, "tex", StringComparison.Ordinal)) + { return string.Empty; - } - }); + } + + try + { + using var stream = new FileStream(FilePaths[0], FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(stream); + reader.BaseStream.Position = 4; + var format = (TexFile.TextureFormat)reader.ReadInt32(); + return format.ToString(); + } + catch + { + return "Unknown"; + } + }); } } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index fdf2ec3..253847c 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -324,7 +324,28 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber EnsureIsOnFramework(); playerPointer ??= GetPlayerPtr(); if (playerPointer == IntPtr.Zero) return IntPtr.Zero; - return _objectTable.GetObjectAddress(((GameObject*)playerPointer)->ObjectIndex + 1); + + var playerAddress = playerPointer.Value; + var ownerEntityId = ((Character*)playerAddress)->EntityId; + if (ownerEntityId == 0) return IntPtr.Zero; + + if (playerAddress == _actorObjectService.LocalPlayerAddress) + { + var localOwned = _actorObjectService.LocalMinionOrMountAddress; + if (localOwned != nint.Zero) + { + return localOwned; + } + } + + var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind => + kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion); + if (ownedObject != nint.Zero) + { + return ownedObject; + } + + return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); } public async Task GetMinionOrMountAsync(IntPtr? playerPointer = null) @@ -347,6 +368,62 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false); } + private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func matchesKind) + { + if (ownerEntityId == 0) + { + return nint.Zero; + } + + foreach (var obj in _objectTable) + { + if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress) + { + continue; + } + + if (!matchesKind(obj.ObjectKind)) + { + continue; + } + + var candidate = (GameObject*)obj.Address; + if (ResolveOwnerId(candidate) == ownerEntityId) + { + return obj.Address; + } + } + + return nint.Zero; + } + + private static unsafe uint ResolveOwnerId(GameObject* gameObject) + { + if (gameObject == null) + { + return 0; + } + + if (gameObject->OwnerId != 0) + { + return gameObject->OwnerId; + } + + var character = (Character*)gameObject; + if (character == null) + { + return 0; + } + + if (character->CompanionOwnerId != 0) + { + return character->CompanionOwnerId; + } + + var parent = character->GetParentCharacter(); + return parent != null ? parent->EntityId : 0; + } + public async Task GetPlayerCharacterAsync() { return await RunOnFrameworkThread(GetPlayerCharacter).ConfigureAwait(false); @@ -393,6 +470,24 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber return await RunOnFrameworkThread(() => _cid.Value.ToString().GetHash256()).ConfigureAwait(false); } + public static unsafe bool TryGetHashedCID(IPlayerCharacter? playerCharacter, out string hashedCid) + { + hashedCid = string.Empty; + if (playerCharacter == null) + return false; + + var address = playerCharacter.Address; + if (address == nint.Zero) + return false; + + var cid = ((BattleChara*)address)->Character.ContentId; + if (cid == 0) + return false; + + hashedCid = cid.ToString().GetHash256(); + return true; + } + public unsafe static string GetHashedCIDFromPlayerPointer(nint ptr) { return ((BattleChara*)ptr)->Character.ContentId.ToString().GetHash256(); @@ -516,17 +611,13 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var fileName = Path.GetFileNameWithoutExtension(callerFilePath); await _performanceCollector.LogPerformance(this, $"RunOnFramework:Act/{fileName}>{callerMember}:{callerLineNumber}", async () => { - if (!_framework.IsInFrameworkUpdateThread) + if (_framework.IsInFrameworkUpdateThread) { - await _framework.RunOnFrameworkThread(act).ContinueWith((_) => Task.CompletedTask).ConfigureAwait(false); - while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered - { - _logger.LogTrace("Still on framework"); - await Task.Delay(1).ConfigureAwait(false); - } - } - else act(); + return; + } + + await _framework.RunOnFrameworkThread(act).ConfigureAwait(false); }).ConfigureAwait(false); } @@ -535,18 +626,12 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber var fileName = Path.GetFileNameWithoutExtension(callerFilePath); return await _performanceCollector.LogPerformance(this, $"RunOnFramework:Func<{typeof(T)}>/{fileName}>{callerMember}:{callerLineNumber}", async () => { - if (!_framework.IsInFrameworkUpdateThread) + if (_framework.IsInFrameworkUpdateThread) { - var result = await _framework.RunOnFrameworkThread(func).ContinueWith((task) => task.Result).ConfigureAwait(false); - while (_framework.IsInFrameworkUpdateThread) // yield the thread again, should technically never be triggered - { - _logger.LogTrace("Still on framework"); - await Task.Delay(1).ConfigureAwait(false); - } - return result; + return func.Invoke(); } - return func.Invoke(); + return await _framework.RunOnFrameworkThread(func).ConfigureAwait(false); }).ConfigureAwait(false); } diff --git a/LightlessSync/Services/Events/Event.cs b/LightlessSync/Services/Events/Event.cs index ca540b9..9725d49 100644 --- a/LightlessSync/Services/Events/Event.cs +++ b/LightlessSync/Services/Events/Event.cs @@ -6,6 +6,8 @@ public record Event { public DateTime EventTime { get; } public string UID { get; } + public string AliasOrUid { get; } + public string UserId { get; } public string Character { get; } public string EventSource { get; } public EventSeverity EventSeverity { get; } @@ -14,7 +16,9 @@ public record Event public Event(string? Character, UserData UserData, string EventSource, EventSeverity EventSeverity, string Message) { EventTime = DateTime.Now; - this.UID = UserData.AliasOrUID; + this.UserId = UserData.UID; + this.AliasOrUid = UserData.AliasOrUID; + this.UID = UserData.UID; this.Character = Character ?? string.Empty; this.EventSource = EventSource; this.EventSeverity = EventSeverity; @@ -37,7 +41,7 @@ public record Event else { if (string.IsNullOrEmpty(Character)) - return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}> {Message}"; + return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{AliasOrUid}> {Message}"; else return $"{EventTime:HH:mm:ss.fff}\t[{EventSource}]{{{(int)EventSeverity}}}\t<{UID}\\{Character}> {Message}"; } diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs index 8725d07..bba27cd 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionCapabilities.cs @@ -8,15 +8,21 @@ internal static class TextureCompressionCapabilities private static readonly ImmutableDictionary TexTargets = new Dictionary { - [TextureCompressionTarget.BC7] = TextureType.Bc7Tex, + [TextureCompressionTarget.BC1] = TextureType.Bc1Tex, [TextureCompressionTarget.BC3] = TextureType.Bc3Tex, + [TextureCompressionTarget.BC4] = TextureType.Bc4Tex, + [TextureCompressionTarget.BC5] = TextureType.Bc5Tex, + [TextureCompressionTarget.BC7] = TextureType.Bc7Tex, }.ToImmutableDictionary(); private static readonly ImmutableDictionary DdsTargets = new Dictionary { - [TextureCompressionTarget.BC7] = TextureType.Bc7Dds, + [TextureCompressionTarget.BC1] = TextureType.Bc1Dds, [TextureCompressionTarget.BC3] = TextureType.Bc3Dds, + [TextureCompressionTarget.BC4] = TextureType.Bc4Dds, + [TextureCompressionTarget.BC5] = TextureType.Bc5Dds, + [TextureCompressionTarget.BC7] = TextureType.Bc7Dds, }.ToImmutableDictionary(); private static readonly TextureCompressionTarget[] SelectableTargetsCache = TexTargets diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index a7b42f5..7a09ae7 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -104,6 +104,37 @@ public sealed class TextureDownscaleService return originalPath; } + public Task WaitForPendingJobsAsync(IEnumerable? hashes, CancellationToken token) + { + if (hashes is null) + { + return Task.CompletedTask; + } + + var pending = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var hash in hashes) + { + if (string.IsNullOrEmpty(hash) || !seen.Add(hash)) + { + continue; + } + + if (_activeJobs.TryGetValue(hash, out var job)) + { + pending.Add(job); + } + } + + if (pending.Count == 0) + { + return Task.CompletedTask; + } + + return Task.WhenAll(pending).WaitAsync(token); + } + private async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind) { TexHeaderInfo? headerInfo = null; diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs index 010f9be..f360ba3 100644 --- a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -88,26 +88,39 @@ public sealed class TextureMetadataHelper private static readonly (TextureMapKind Kind, string Token)[] MapTokens = { - (TextureMapKind.Normal, "_n"), + (TextureMapKind.Normal, "_n."), + (TextureMapKind.Normal, "_n_"), (TextureMapKind.Normal, "_normal"), + (TextureMapKind.Normal, "normal_"), (TextureMapKind.Normal, "_norm"), + (TextureMapKind.Normal, "norm_"), - (TextureMapKind.Mask, "_m"), + (TextureMapKind.Mask, "_m."), + (TextureMapKind.Mask, "_m_"), (TextureMapKind.Mask, "_mask"), + (TextureMapKind.Mask, "mask_"), (TextureMapKind.Mask, "_msk"), - (TextureMapKind.Specular, "_s"), + (TextureMapKind.Specular, "_s."), + (TextureMapKind.Specular, "_s_"), (TextureMapKind.Specular, "_spec"), + (TextureMapKind.Specular, "_specular"), + (TextureMapKind.Specular, "specular_"), - (TextureMapKind.Index, "_id"), + (TextureMapKind.Index, "_id."), + (TextureMapKind.Index, "_id_"), (TextureMapKind.Index, "_idx"), (TextureMapKind.Index, "_index"), + (TextureMapKind.Index, "index_"), (TextureMapKind.Index, "_multi"), - (TextureMapKind.Diffuse, "_d"), + (TextureMapKind.Diffuse, "_d."), + (TextureMapKind.Diffuse, "_d_"), (TextureMapKind.Diffuse, "_diff"), - (TextureMapKind.Diffuse, "_b"), - (TextureMapKind.Diffuse, "_base") + (TextureMapKind.Diffuse, "_b."), + (TextureMapKind.Diffuse, "_b_"), + (TextureMapKind.Diffuse, "_base"), + (TextureMapKind.Diffuse, "base_") }; private const string TextureSegment = "/texture/"; @@ -376,73 +389,83 @@ public sealed class TextureMetadataHelper private static TextureMapKind GuessMapFromFileName(string path) { var normalized = Normalize(path); - var fileName = Path.GetFileNameWithoutExtension(normalized); - if (string.IsNullOrEmpty(fileName)) + var fileNameWithExtension = Path.GetFileName(normalized); + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(normalized); + if (string.IsNullOrEmpty(fileNameWithExtension) && string.IsNullOrEmpty(fileNameWithoutExtension)) return TextureMapKind.Unknown; foreach (var (kind, token) in MapTokens) { - if (fileName.Contains(token, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(fileNameWithExtension) && + fileNameWithExtension.Contains(token, StringComparison.OrdinalIgnoreCase)) + return kind; + + if (!string.IsNullOrEmpty(fileNameWithoutExtension) && + fileNameWithoutExtension.Contains(token, StringComparison.OrdinalIgnoreCase)) return kind; } return TextureMapKind.Unknown; } + private static readonly (string Token, TextureCompressionTarget Target)[] FormatTargetTokens = + { + ("BC1", TextureCompressionTarget.BC1), + ("DXT1", TextureCompressionTarget.BC1), + ("BC3", TextureCompressionTarget.BC3), + ("DXT3", TextureCompressionTarget.BC3), + ("DXT5", TextureCompressionTarget.BC3), + ("BC4", TextureCompressionTarget.BC4), + ("ATI1", TextureCompressionTarget.BC4), + ("BC5", TextureCompressionTarget.BC5), + ("ATI2", TextureCompressionTarget.BC5), + ("3DC", TextureCompressionTarget.BC5), + ("BC7", TextureCompressionTarget.BC7), + ("BPTC", TextureCompressionTarget.BC7) + }; // idk man + public static bool TryMapFormatToTarget(string? format, out TextureCompressionTarget target) { var normalized = (format ?? string.Empty).ToUpperInvariant(); - if (normalized.Contains("BC1", StringComparison.Ordinal)) + foreach (var (token, mappedTarget) in FormatTargetTokens) { - target = TextureCompressionTarget.BC1; - return true; - } - - if (normalized.Contains("BC3", StringComparison.Ordinal)) - { - target = TextureCompressionTarget.BC3; - return true; - } - - if (normalized.Contains("BC4", StringComparison.Ordinal)) - { - target = TextureCompressionTarget.BC4; - return true; - } - - if (normalized.Contains("BC5", StringComparison.Ordinal)) - { - target = TextureCompressionTarget.BC5; - return true; - } - - if (normalized.Contains("BC7", StringComparison.Ordinal)) - { - target = TextureCompressionTarget.BC7; - return true; + if (normalized.Contains(token, StringComparison.Ordinal)) + { + target = mappedTarget; + return true; + } } target = TextureCompressionTarget.BC7; return false; } - public static (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget(string? format, TextureMapKind mapKind) + public static (TextureCompressionTarget Target, string Reason)? GetSuggestedTarget( + string? format, + TextureMapKind mapKind, + string? texturePath = null) { TextureCompressionTarget? current = null; if (TryMapFormatToTarget(format, out var mapped)) current = mapped; + var prefersBc4 = IsFacePaintOrMarkTexture(texturePath); + var suggestion = mapKind switch { TextureMapKind.Normal => TextureCompressionTarget.BC7, - TextureMapKind.Mask => TextureCompressionTarget.BC4, - TextureMapKind.Index => TextureCompressionTarget.BC3, - TextureMapKind.Specular => TextureCompressionTarget.BC4, + TextureMapKind.Mask => TextureCompressionTarget.BC7, + TextureMapKind.Index => TextureCompressionTarget.BC5, + TextureMapKind.Specular => TextureCompressionTarget.BC3, TextureMapKind.Diffuse => TextureCompressionTarget.BC7, _ => TextureCompressionTarget.BC7 }; - if (mapKind == TextureMapKind.Diffuse && !HasAlphaHint(format)) + if (prefersBc4) + { + suggestion = TextureCompressionTarget.BC4; + } + else if (mapKind == TextureMapKind.Diffuse && current is null && !HasAlphaHint(format)) suggestion = TextureCompressionTarget.BC1; if (current == suggestion) @@ -498,14 +521,41 @@ public sealed class TextureMetadataHelper || normalized.Contains("skin", StringComparison.OrdinalIgnoreCase) || normalized.Contains("bibo", StringComparison.OrdinalIgnoreCase)) return "Skin"; - if (normalized.Contains("decal_face", StringComparison.OrdinalIgnoreCase)) + if (IsFacePaintPath(normalized)) return "Face Paint"; + if (IsLegacyMarkPath(normalized)) + return "Legacy Mark"; if (normalized.Contains("decal_equip", StringComparison.OrdinalIgnoreCase)) return "Equipment Decal"; return "Customization"; } + private static bool IsFacePaintOrMarkTexture(string? texturePath) + { + var normalized = Normalize(texturePath); + return IsFacePaintPath(normalized) || IsLegacyMarkPath(normalized); + } + + private static bool IsFacePaintPath(string? normalizedPath) + { + if (string.IsNullOrEmpty(normalizedPath)) + return false; + + return normalizedPath.Contains("decal_face", StringComparison.Ordinal) + || normalizedPath.Contains("facepaint", StringComparison.Ordinal) + || normalizedPath.Contains("_decal_", StringComparison.Ordinal); + } + + private static bool IsLegacyMarkPath(string? normalizedPath) + { + if (string.IsNullOrEmpty(normalizedPath)) + return false; + + return normalizedPath.Contains("transparent", StringComparison.Ordinal) + || normalizedPath.Contains("transparent.tex", StringComparison.Ordinal); + } + private static bool HasAlphaHint(string? format) { if (string.IsNullOrEmpty(format)) diff --git a/LightlessSync/Services/UiFactory.cs b/LightlessSync/Services/UiFactory.cs index 7237936..9b90830 100644 --- a/LightlessSync/Services/UiFactory.cs +++ b/LightlessSync/Services/UiFactory.cs @@ -1,4 +1,3 @@ -using Dalamud.Interface.ImGuiFileDialog; using LightlessSync.API.Data; using LightlessSync.API.Dto.Group; using LightlessSync.PlayerData.Pairs; @@ -23,6 +22,7 @@ public class UiFactory private readonly LightlessProfileManager _lightlessProfileManager; private readonly PerformanceCollectorService _performanceCollectorService; private readonly ProfileTagService _profileTagService; + private readonly DalamudUtilService _dalamudUtilService; public UiFactory( ILoggerFactory loggerFactory, @@ -33,7 +33,8 @@ public class UiFactory ServerConfigurationManager serverConfigManager, LightlessProfileManager lightlessProfileManager, PerformanceCollectorService performanceCollectorService, - ProfileTagService profileTagService) + ProfileTagService profileTagService, + DalamudUtilService dalamudUtilService) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -44,6 +45,7 @@ public class UiFactory _lightlessProfileManager = lightlessProfileManager; _performanceCollectorService = performanceCollectorService; _profileTagService = profileTagService; + _dalamudUtilService = dalamudUtilService; } public SyncshellAdminUI CreateSyncshellAdminUi(GroupFullInfoDto dto) @@ -60,76 +62,16 @@ public class UiFactory } public StandaloneProfileUi CreateStandaloneProfileUi(Pair pair) - { - return new StandaloneProfileUi( - _loggerFactory.CreateLogger(), - _lightlessMediator, - _uiSharedService, - _serverConfigManager, - _profileTagService, - _lightlessProfileManager, - _pairUiService, - pair, - pair.UserData, - null, - false, - null, - _performanceCollectorService); - } + => CreateStandaloneProfileUiInternal(pair, pair.UserData, null, false, null); public StandaloneProfileUi CreateStandaloneProfileUi(UserData userData) - { - return new StandaloneProfileUi( - _loggerFactory.CreateLogger(), - _lightlessMediator, - _uiSharedService, - _serverConfigManager, - _profileTagService, - _lightlessProfileManager, - _pairUiService, - null, - userData, - null, - false, - null, - _performanceCollectorService); - } + => CreateStandaloneProfileUiInternal(null, userData, null, false, null); public StandaloneProfileUi CreateLightfinderProfileUi(UserData userData, string hashedCid) - { - return new StandaloneProfileUi( - _loggerFactory.CreateLogger(), - _lightlessMediator, - _uiSharedService, - _serverConfigManager, - _profileTagService, - _lightlessProfileManager, - _pairUiService, - null, - userData, - null, - true, - hashedCid, - _performanceCollectorService); - } + => CreateStandaloneProfileUiInternal(null, userData, null, true, hashedCid); public StandaloneProfileUi CreateStandaloneGroupProfileUi(GroupData groupInfo) - { - return new StandaloneProfileUi( - _loggerFactory.CreateLogger(), - _lightlessMediator, - _uiSharedService, - _serverConfigManager, - _profileTagService, - _lightlessProfileManager, - _pairUiService, - null, - null, - groupInfo, - false, - null, - _performanceCollectorService); - } + => CreateStandaloneProfileUiInternal(null, null, groupInfo, false, null); public PermissionWindowUI CreatePermissionPopupUi(Pair pair) { @@ -141,4 +83,28 @@ public class UiFactory _apiController, _performanceCollectorService); } + + private StandaloneProfileUi CreateStandaloneProfileUiInternal( + Pair? pair, + UserData? userData, + GroupData? groupData, + bool isLightfinderContext, + string? lightfinderCid) + { + return new StandaloneProfileUi( + _loggerFactory.CreateLogger(), + _lightlessMediator, + _uiSharedService, + _serverConfigManager, + _profileTagService, + dalamudUtilService: _dalamudUtilService, + lightlessProfileManager: _lightlessProfileManager, + pairUiService: _pairUiService, + pair: pair, + userData: userData, + groupData: groupData, + isLightfinderContext: isLightfinderContext, + lightfinderCid: lightfinderCid, + performanceCollector: _performanceCollectorService); + } } diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 65c6dfa..2a962a6 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -94,7 +94,7 @@ public class CompactUi : WindowMediatorSubscriberBase IpcManager ipcManager, LightFinderService broadcastService, CharacterAnalyzer characterAnalyzer, - PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) + PlayerPerformanceConfigService playerPerformanceConfig, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, PairLedger pairLedger, LightFinderScannerService lightFinderScannerService) : base(logger, mediator, "###LightlessSyncMainUI", performanceCollectorService) { _uiSharedService = uiShared; _configService = configService; @@ -114,44 +114,17 @@ public class CompactUi : WindowMediatorSubscriberBase _broadcastService = broadcastService; _pairLedger = pairLedger; _dalamudUtilService = dalamudUtilService; - _tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService); + _tabMenu = new TopTabMenu(Mediator, _apiController, _uiSharedService, pairRequestService, dalamudUtilService, lightlessNotificationService, broadcastService, lightFinderScannerService); Mediator.Subscribe(this, msg => RegisterFocusCharacter(msg.Pair)); - AllowPinning = true; - AllowClickthrough = false; - TitleBarButtons = - [ - new TitleBarButton() - { - Icon = FontAwesomeIcon.Cog, - Click = (msg) => - { - Mediator.Publish(new UiToggleMessage(typeof(SettingsUi))); - }, - IconOffset = new(2,1), - ShowTooltip = () => - { - ImGui.BeginTooltip(); - ImGui.Text("Open Lightless Settings"); - ImGui.EndTooltip(); - } - }, - new TitleBarButton() - { - Icon = FontAwesomeIcon.Book, - Click = (msg) => - { - Mediator.Publish(new UiToggleMessage(typeof(EventViewerUI))); - }, - IconOffset = new(2,1), - ShowTooltip = () => - { - ImGui.BeginTooltip(); - ImGui.Text("Open Lightless Event Viewer"); - ImGui.EndTooltip(); - } - }, - ]; + WindowBuilder.For(this) + .AllowPinning(true) + .AllowClickthrough(false) + .SetSizeConstraints(new Vector2(375, 400), new Vector2(375, 2000)) + .AddFlags(ImGuiWindowFlags.NoDocking) + .AddTitleBarButton(FontAwesomeIcon.Cog, "Open Lightless Settings", () => Mediator.Publish(new UiToggleMessage(typeof(SettingsUi)))) + .AddTitleBarButton(FontAwesomeIcon.Book, "Open Lightless Event Viewer", () => Mediator.Publish(new UiToggleMessage(typeof(EventViewerUI)))) + .Apply(); _drawFolders = [.. DrawFolders]; @@ -172,13 +145,6 @@ public class CompactUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, (msg) => _currentDownloads.TryRemove(msg.DownloadId, out _)); Mediator.Subscribe(this, (msg) => _drawFolders = DrawFolders.ToList()); - Flags |= ImGuiWindowFlags.NoDocking; - - SizeConstraints = new WindowSizeConstraints() - { - MinimumSize = new Vector2(375, 400), - MaximumSize = new Vector2(375, 2000), - }; _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; _lightlessMediator = mediator; @@ -534,7 +500,8 @@ public class CompactUi : WindowMediatorSubscriberBase private void DrawUIDHeader() { - var uidText = GetUidText(); + var uidText = _apiController.ServerState.GetUidText(_apiController.DisplayName); + var uidColor = _apiController.ServerState.GetUidColor(); Vector4? vanityTextColor = null; Vector4? vanityGlowColor = null; @@ -667,7 +634,7 @@ public class CompactUi : WindowMediatorSubscriberBase } else { - ImGui.TextColored(GetUidColor(), uidText); + ImGui.TextColored(uidColor, uidText); } } @@ -754,7 +721,7 @@ public class CompactUi : WindowMediatorSubscriberBase } else { - ImGui.TextColored(GetUidColor(), _apiController.UID); + ImGui.TextColored(uidColor, _apiController.UID); } if (ImGui.IsItemHovered()) @@ -781,7 +748,7 @@ public class CompactUi : WindowMediatorSubscriberBase } else { - UiSharedService.ColorTextWrapped(GetServerError(), GetUidColor()); + UiSharedService.ColorTextWrapped(_apiController.ServerState.GetServerError(_apiController.AuthFailureMessage), uidColor); } } @@ -1048,73 +1015,6 @@ public class CompactUi : WindowMediatorSubscriberBase return SortGroupEntries(entries, group); } - private string GetServerError() - { - return _apiController.ServerState switch - { - ServerState.Connecting => "Attempting to connect to the server.", - ServerState.Reconnecting => "Connection to server interrupted, attempting to reconnect to the server.", - ServerState.Disconnected => "You are currently disconnected from the Lightless Sync server.", - ServerState.Disconnecting => "Disconnecting from the server", - ServerState.Unauthorized => "Server Response: " + _apiController.AuthFailureMessage, - ServerState.Offline => "Your selected Lightless Sync server is currently offline.", - ServerState.VersionMisMatch => - "Your plugin or the server you are connecting to is out of date. Please update your plugin now. If you already did so, contact the server provider to update their server to the latest version.", - ServerState.RateLimited => "You are rate limited for (re)connecting too often. Disconnect, wait 10 minutes and try again.", - ServerState.Connected => string.Empty, - ServerState.NoSecretKey => "You have no secret key set for this current character. Open Settings -> Service Settings and set a secret key for the current character. You can reuse the same secret key for multiple characters.", - ServerState.MultiChara => "Your Character Configuration has multiple characters configured with same name and world. You will not be able to connect until you fix this issue. Remove the duplicates from the configuration in Settings -> Service Settings -> Character Management and reconnect manually after.", - ServerState.OAuthMisconfigured => "OAuth2 is enabled but not fully configured, verify in the Settings -> Service Settings that you have OAuth2 connected and, importantly, a UID assigned to your current character.", - ServerState.OAuthLoginTokenStale => "Your OAuth2 login token is stale and cannot be used to renew. Go to the Settings -> Service Settings and unlink then relink your OAuth2 configuration.", - ServerState.NoAutoLogon => "This character has automatic login into Lightless disabled. Press the connect button to connect to Lightless.", - _ => string.Empty - }; - } - - private Vector4 GetUidColor() - { - return _apiController.ServerState switch - { - ServerState.Connecting => UIColors.Get("LightlessYellow"), - ServerState.Reconnecting => UIColors.Get("DimRed"), - ServerState.Connected => UIColors.Get("LightlessPurple"), - ServerState.Disconnected => UIColors.Get("LightlessYellow"), - ServerState.Disconnecting => UIColors.Get("LightlessYellow"), - ServerState.Unauthorized => UIColors.Get("DimRed"), - ServerState.VersionMisMatch => UIColors.Get("DimRed"), - ServerState.Offline => UIColors.Get("DimRed"), - ServerState.RateLimited => UIColors.Get("LightlessYellow"), - ServerState.NoSecretKey => UIColors.Get("LightlessYellow"), - ServerState.MultiChara => UIColors.Get("LightlessYellow"), - ServerState.OAuthMisconfigured => UIColors.Get("DimRed"), - ServerState.OAuthLoginTokenStale => UIColors.Get("DimRed"), - ServerState.NoAutoLogon => UIColors.Get("LightlessYellow"), - _ => UIColors.Get("DimRed") - }; - } - - private string GetUidText() - { - return _apiController.ServerState switch - { - ServerState.Reconnecting => "Reconnecting", - ServerState.Connecting => "Connecting", - ServerState.Disconnected => "Disconnected", - ServerState.Disconnecting => "Disconnecting", - ServerState.Unauthorized => "Unauthorized", - ServerState.VersionMisMatch => "Version mismatch", - ServerState.Offline => "Unavailable", - ServerState.RateLimited => "Rate Limited", - ServerState.NoSecretKey => "No Secret Key", - ServerState.MultiChara => "Duplicate Characters", - ServerState.OAuthMisconfigured => "Misconfigured OAuth2", - ServerState.OAuthLoginTokenStale => "Stale OAuth2", - ServerState.NoAutoLogon => "Auto Login disabled", - ServerState.Connected => _apiController.DisplayName, - _ => string.Empty - }; - } - private void UiSharedService_GposeEnd() { IsOpen = _wasOpen; diff --git a/LightlessSync/UI/CreateSyncshellUI.cs b/LightlessSync/UI/CreateSyncshellUI.cs index 215156b..2198a42 100644 --- a/LightlessSync/UI/CreateSyncshellUI.cs +++ b/LightlessSync/UI/CreateSyncshellUI.cs @@ -5,6 +5,7 @@ using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; using System.Numerics; @@ -24,13 +25,10 @@ public class CreateSyncshellUI : WindowMediatorSubscriberBase { _apiController = apiController; _uiSharedService = uiSharedService; - SizeConstraints = new() - { - MinimumSize = new(550, 330), - MaximumSize = new(550, 330) - }; - - Flags = ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse; + WindowBuilder.For(this) + .SetFixedSize(new Vector2(550, 330)) + .AddFlags(ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse) + .Apply(); Mediator.Subscribe(this, (_) => IsOpen = false); } diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 932653d..94c8add 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -110,19 +110,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { _hasUpdate = true; }); - SizeConstraints = new() - { - MinimumSize = new() - { - X = 1650, - Y = 1000 - }, - MaximumSize = new() - { - X = 3840, - Y = 2160 - } - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(1650, 1000), new Vector2(3840, 2160)) + .Apply(); _conversionProgress.ProgressChanged += ConversionProgress_ProgressChanged; } @@ -811,7 +801,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var category = TextureMetadataHelper.DetermineCategory(classificationPath); var slot = TextureMetadataHelper.DetermineSlot(category, classificationPath); var format = entry.Format.Value; - var suggestion = TextureMetadataHelper.GetSuggestedTarget(format, mapKind); + var suggestion = TextureMetadataHelper.GetSuggestedTarget(format, mapKind, classificationPath); TextureCompressionTarget? currentTarget = TextureMetadataHelper.TryMapFormatToTarget(format, out var mappedTarget) ? mappedTarget : null; @@ -2131,8 +2121,16 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase }); DrawSelectableColumn(isSelected, () => { + Action? tooltipAction = null; ImGui.TextUnformatted(row.Format); - return null; + if (!row.IsAlreadyCompressed) + { + ImGui.SameLine(0f, 4f * ImGuiHelpers.GlobalScale); + var iconColor = isSelected ? SelectedTextureRowTextColor : UIColors.Get("LightlessYellow"); + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, iconColor); + tooltipAction = () => UiSharedService.AttachToolTip("Run compression to reduce file size."); + } + return tooltipAction; }); DrawSelectableColumn(isSelected, () => diff --git a/LightlessSync/UI/EditProfileUi.cs b/LightlessSync/UI/EditProfileUi.cs index 4682321..53d6b9e 100644 --- a/LightlessSync/UI/EditProfileUi.cs +++ b/LightlessSync/UI/EditProfileUi.cs @@ -52,7 +52,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase "bmp" }; private const string _imageFileDialogFilter = "Images{.png,.jpg,.jpeg,.webp,.bmp}"; - private readonly List _tagEditorSelection = []; + private readonly List _tagEditorSelection; private int[] _profileTagIds = []; private readonly List _tagPreviewSegments = new(); private enum ProfileEditorMode @@ -120,6 +120,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase _fileDialogManager = fileDialogManager; _lightlessProfileManager = lightlessProfileManager; _profileTagService = profileTagService; + _tagEditorSelection = new List(_maxProfileTags); Mediator.Subscribe(this, (_) => { _wasOpen = IsOpen; IsOpen = false; }); Mediator.Subscribe(this, (_) => IsOpen = _wasOpen); @@ -346,8 +347,8 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase SyncProfileState(profile); DrawSection("Profile Preview", scale, () => DrawProfileSnapshot(profile, scale)); - DrawSection("Profile Image", scale, () => DrawProfileImageControls(profile, scale)); - DrawSection("Profile Banner", scale, () => DrawProfileBannerControls(profile, scale)); + DrawSection("Profile Image", scale, () => DrawProfileImageControls(profile)); + DrawSection("Profile Banner", scale, () => DrawProfileBannerControls(profile)); DrawSection("Profile Description", scale, () => DrawProfileDescriptionEditor(profile, scale)); DrawSection("Profile Tags", scale, () => DrawProfileTagsEditor(profile, scale)); DrawSection("Visibility", scale, () => DrawProfileVisibilityControls()); @@ -434,7 +435,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } - private void DrawProfileImageControls(LightlessUserProfileData profile, float scale) + private void DrawProfileImageControls(LightlessUserProfileData profile) { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile pictures must be 512x512 and under 2 MiB."); @@ -498,7 +499,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } } - private void DrawProfileBannerControls(LightlessUserProfileData profile, float scale) + private void DrawProfileBannerControls(LightlessUserProfileData profile) { _uiSharedService.DrawNoteLine("# ", UIColors.Get("LightlessBlue"), "Profile banners must be 840x260 and under 2 MiB."); @@ -950,21 +951,6 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase ProfileTagRenderer.RenderTag(tag, rectMin, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _tagPreviewSegments, ResolveIconWrap, _logger); } - private void DrawInfoCell(string displayName, string idLabel, float rowHeight, ImGuiStylePtr style) - { - var cellStart = ImGui.GetCursorPos(); - var nameSize = ImGui.CalcTextSize(displayName); - var idSize = ImGui.CalcTextSize(idLabel); - var totalHeight = nameSize.Y + style.ItemSpacing.Y + idSize.Y; - var offsetY = MathF.Max(0f, (rowHeight - totalHeight) * 0.5f) - style.CellPadding.Y; - if (offsetY < 0f) offsetY = 0f; - - ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, cellStart.Y + offsetY)); - ImGui.TextUnformatted(displayName); - ImGui.SetCursorPos(new Vector2(cellStart.X + style.CellPadding.X, ImGui.GetCursorPosY() + style.ItemSpacing.Y)); - ImGui.TextDisabled(idLabel); - } - private void DrawReorderCell( string contextPrefix, int tagId, @@ -1002,8 +988,6 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase var cellStart = ImGui.GetCursorPos(); var available = ImGui.GetContentRegionAvail(); var buttonHeight = MathF.Max(1f, rowHeight - style.CellPadding.Y * 2f); - var hovered = BlendTowardsWhite(baseColor, 0.15f); - var active = BlendTowardsWhite(baseColor, 0.3f); ImGui.SetCursorPos(new Vector2(cellStart.X, cellStart.Y + style.CellPadding.Y)); using (ImRaii.PushId(idSuffix)) @@ -1013,7 +997,7 @@ public partial class EditProfileUi : WindowMediatorSubscriberBase } } - private bool ColoredButton(string label, Vector4 baseColor, Vector2 size, float scale, bool disabled) + private static bool ColoredButton(string label, Vector4 baseColor, Vector2 size, float scale, bool disabled) { var style = ImGui.GetStyle(); var hovered = BlendTowardsWhite(baseColor, 0.15f); diff --git a/LightlessSync/UI/EventViewerUI.cs b/LightlessSync/UI/EventViewerUI.cs index 7afa1f7..17bcbb2 100644 --- a/LightlessSync/UI/EventViewerUI.cs +++ b/LightlessSync/UI/EventViewerUI.cs @@ -5,6 +5,7 @@ using Dalamud.Interface.Utility.Raii; using LightlessSync.Services; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Globalization; @@ -43,11 +44,9 @@ internal class EventViewerUI : WindowMediatorSubscriberBase { _eventAggregator = eventAggregator; _uiSharedService = uiSharedService; - SizeConstraints = new() - { - MinimumSize = new(600, 500), - MaximumSize = new(1000, 2000) - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(600, 500), new Vector2(1000, 2000)) + .Apply(); _filteredEvents = RecreateFilter(); } diff --git a/LightlessSync/UI/IntroUI.cs b/LightlessSync/UI/IntroUI.cs index 97935c2..4fab7ef 100644 --- a/LightlessSync/UI/IntroUI.cs +++ b/LightlessSync/UI/IntroUI.cs @@ -10,6 +10,7 @@ using LightlessSync.Localization; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Numerics; using System.Text.RegularExpressions; @@ -46,11 +47,9 @@ public partial class IntroUi : WindowMediatorSubscriberBase ShowCloseButton = false; RespectCloseHotkey = false; - SizeConstraints = new WindowSizeConstraints() - { - MinimumSize = new Vector2(600, 400), - MaximumSize = new Vector2(600, 2000), - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 2000)) + .Apply(); GetToSLocalization(); diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index 9f118a3..fa88475 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -2,7 +2,6 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; -using Dalamud.Utility; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; @@ -47,11 +46,9 @@ namespace LightlessSync.UI _broadcastScannerService = broadcastScannerService; IsOpen = false; - this.SizeConstraints = new() - { - MinimumSize = new(600, 465), - MaximumSize = new(750, 525) - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(600, 465), new Vector2(750, 525)) + .Apply(); } private void RebuildSyncshellDropdownOptions() diff --git a/LightlessSync/UI/PermissionWindowUI.cs b/LightlessSync/UI/PermissionWindowUI.cs index 45be154..5dee098 100644 --- a/LightlessSync/UI/PermissionWindowUI.cs +++ b/LightlessSync/UI/PermissionWindowUI.cs @@ -9,6 +9,7 @@ using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.Extensions.Logging; +using System.Numerics; namespace LightlessSync.UI; @@ -28,12 +29,10 @@ public class PermissionWindowUI : WindowMediatorSubscriberBase _uiSharedService = uiSharedService; _apiController = apiController; _ownPermissions = pair.UserPair.OwnPermissions.DeepClone(); - Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize; - SizeConstraints = new() - { - MinimumSize = new(450, 100), - MaximumSize = new(450, 500) - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(450, 100), new Vector2(450, 500)) + .AddFlags(ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize) + .Apply(); IsOpen = true; } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 9602f5a..4ce64ac 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1,5 +1,4 @@ using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.Text; using Dalamud.Interface; using Dalamud.Interface.Colors; @@ -8,6 +7,7 @@ using Dalamud.Interface.Utility.Raii; using Dalamud.Utility; using LightlessSync.API.Data; using LightlessSync.API.Data.Comparer; +using LightlessSync.API.Data.Enum; using LightlessSync.API.Routes; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; @@ -18,9 +18,11 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.ActorTracking; +using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Models; using LightlessSync.UI.Services; using LightlessSync.UI.Style; using LightlessSync.Utils; @@ -39,7 +41,6 @@ using System.Net.Http.Json; using System.Numerics; using System.Text; using System.Text.Json; -using static Penumbra.GameData.Files.ShpkFile; namespace LightlessSync.UI; @@ -62,6 +63,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly PerformanceCollectorService _performanceCollector; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PairProcessingLimiter _pairProcessingLimiter; + private readonly EventAggregator _eventAggregator; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly UiSharedService _uiShared; private readonly IProgress<(int, int, FileCacheEntity)> _validationProgress; @@ -75,11 +77,13 @@ public class SettingsUi : WindowMediatorSubscriberBase private bool _readClearCache = false; private int _selectedEntry = -1; private string _uidToAddForIgnore = string.Empty; - private string _lightfinderIconInput = string.Empty; - private bool _lightfinderIconInputInitialized = false; - private int _lightfinderIconPresetIndex = -1; private bool _selectGeneralTabOnNextDraw = false; + private string _pairDebugFilter = string.Empty; + private bool _pairDebugVisibleOnly = true; + private bool _pairDiagnosticsEnabled; + private string? _selectedPairDebugUid = null; private static readonly LightlessConfig DefaultConfig = new(); + private static readonly JsonSerializerOptions DebugJsonOptions = new() { WriteIndented = true }; private MainSettingsTab _selectedMainTab = MainSettingsTab.General; private TransferSettingsTab _selectedTransferTab = TransferSettingsTab.Transfers; private ServerSettingsTab _selectedServerTab = ServerSettingsTab.CharacterManagement; @@ -143,15 +147,6 @@ public class SettingsUi : WindowMediatorSubscriberBase PermissionSettings, } - 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 CancellationTokenSource? _validationCts; private Task>? _validationTask; private bool _wasOpen = false; @@ -162,6 +157,7 @@ public class SettingsUi : WindowMediatorSubscriberBase ServerConfigurationManager serverConfigurationManager, PlayerPerformanceConfigService playerPerformanceConfigService, PairProcessingLimiter pairProcessingLimiter, + EventAggregator eventAggregator, LightlessMediator mediator, PerformanceCollectorService performanceCollector, FileUploadManager fileTransferManager, FileTransferOrchestrator fileTransferOrchestrator, @@ -179,6 +175,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _serverConfigurationManager = serverConfigurationManager; _playerPerformanceConfigService = playerPerformanceConfigService; _pairProcessingLimiter = pairProcessingLimiter; + _eventAggregator = eventAggregator; _performanceCollector = performanceCollector; _fileTransferManager = fileTransferManager; _fileTransferOrchestrator = fileTransferOrchestrator; @@ -192,34 +189,14 @@ public class SettingsUi : WindowMediatorSubscriberBase _uiShared = uiShared; _nameplateService = nameplateService; _actorObjectService = actorObjectService; - AllowClickthrough = false; - AllowPinning = true; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); - SizeConstraints = new WindowSizeConstraints() - { - MinimumSize = new Vector2(900f, 400f), - MaximumSize = new Vector2(900f, 2000f), - }; - - TitleBarButtons = new() - { - new TitleBarButton() - { - Icon = FontAwesomeIcon.FileAlt, - Click = (msg) => - { - Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi))); - }, - IconOffset = new(2, 1), - ShowTooltip = () => - { - ImGui.BeginTooltip(); - ImGui.Text("View Update Notes"); - ImGui.EndTooltip(); - } - } - }; + WindowBuilder.For(this) + .AllowPinning(true) + .AllowClickthrough(false) + .SetSizeConstraints(new Vector2(900f, 400f), new Vector2(900f, 2000f)) + .AddTitleBarButton(FontAwesomeIcon.FileAlt, "View Update Notes", () => Mediator.Publish(new UiToggleMessage(typeof(UpdateNotesUi)))) + .Apply(); Mediator.Subscribe(this, (_) => Toggle()); Mediator.Subscribe(this, (_) => @@ -1309,9 +1286,425 @@ public class SettingsUi : WindowMediatorSubscriberBase UiSharedService.TooltipSeparator + "Keeping LOD enabled can lead to more crashes. Use at your own risk."); + ImGuiHelpers.ScaledDummy(10f); + DrawPairDebugPanel(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 2f); } + private void DrawPairDebugPanel() + { + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessYellow")); + ImGui.TextUnformatted("Pair Diagnostics"); + ImGui.PopStyleColor(); + ImGuiHelpers.ScaledDummy(3f); + + ImGui.Checkbox("Enable Pair Diagnostics", ref _pairDiagnosticsEnabled); + UiSharedService.AttachToolTip("When disabled the UI stops querying pair handlers and no diagnostics are processed."); + + if (!_pairDiagnosticsEnabled) + { + UiSharedService.ColorTextWrapped("Diagnostics are disabled. Enable the toggle above to inspect active pairs.", UIColors.Get("LightlessYellow")); + return; + } + + var snapshot = _pairUiService.GetSnapshot(); + if (snapshot.PairsByUid.Count == 0) + { + UiSharedService.ColorTextWrapped("No pairs are currently tracked. Connect to the service and re-open this panel.", UIColors.Get("LightlessYellow")); + return; + } + + ImGui.SetNextItemWidth(280f * ImGuiHelpers.GlobalScale); + ImGui.InputTextWithHint("##pairDebugFilter", "Search by UID, alias, or player name...", ref _pairDebugFilter, 96); + UiSharedService.AttachToolTip("Filters the list by UID, aliases, or currently cached player name."); + ImGui.SameLine(); + ImGui.Checkbox("Visible pairs only", ref _pairDebugVisibleOnly); + UiSharedService.AttachToolTip("When enabled only currently visible pairs remain in the list."); + + var pairs = snapshot.PairsByUid.Values; + var filteredPairs = pairs + .Where(p => !_pairDebugVisibleOnly || p.IsVisible) + .Where(p => PairMatchesFilter(p, _pairDebugFilter)) + .OrderByDescending(p => p.IsVisible) + .ThenByDescending(p => p.IsOnline) + .ThenBy(p => p.UserData.AliasOrUID, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (filteredPairs.Count == 0) + { + UiSharedService.ColorTextWrapped("No pairs match the current filters.", UIColors.Get("LightlessYellow")); + _selectedPairDebugUid = null; + return; + } + + if (_selectedPairDebugUid is null || !filteredPairs.Any(p => string.Equals(p.UserData.UID, _selectedPairDebugUid, StringComparison.Ordinal))) + { + _selectedPairDebugUid = filteredPairs[0].UserData.UID; + } + + if (_selectedPairDebugUid is null || !snapshot.PairsByUid.TryGetValue(_selectedPairDebugUid, out var selectedPair)) + { + selectedPair = filteredPairs[0]; + } + + var visibleCount = pairs.Count(p => p.IsVisible); + var onlineCount = pairs.Count(p => p.IsOnline); + var totalPairs = snapshot.PairsByUid.Count; + ImGui.TextUnformatted($"Visible: {visibleCount} / {totalPairs}; Online: {onlineCount}"); + + var mainChildHeight = MathF.Max(ImGui.GetTextLineHeightWithSpacing() * 12f, ImGui.GetContentRegionAvail().Y * 0.95f); + if (ImGui.BeginChild("##pairDebugPanel", new Vector2(-1, mainChildHeight), true, ImGuiWindowFlags.HorizontalScrollbar)) + { + var childAvail = ImGui.GetContentRegionAvail(); + var leftWidth = MathF.Max(220f * ImGuiHelpers.GlobalScale, childAvail.X * 0.35f); + leftWidth = MathF.Min(leftWidth, childAvail.X * 0.6f); + if (ImGui.BeginChild("##pairDebugList", new Vector2(leftWidth, 0), true, ImGuiWindowFlags.HorizontalScrollbar)) + { + if (ImGui.BeginTable("##pairDebugTable", 3, ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.ScrollY | ImGuiTableFlags.ScrollX)) + { + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableSetupColumn("", ImGuiTableColumnFlags.WidthFixed, 20f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Pair"); + ImGui.TableSetupColumn("State", ImGuiTableColumnFlags.WidthFixed, 90f * ImGuiHelpers.GlobalScale); + ImGui.TableHeadersRow(); + + foreach (var entry in filteredPairs) + { + var isSelected = string.Equals(entry.UserData.UID, _selectedPairDebugUid, StringComparison.Ordinal); + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + DrawPairStateIndicator(entry); + + ImGui.TableNextColumn(); + if (ImGui.Selectable($"{entry.UserData.AliasOrUID}##pairDebugSelect_{entry.UserData.UID}", isSelected, ImGuiSelectableFlags.SpanAllColumns)) + { + _selectedPairDebugUid = entry.UserData.UID; + } + + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"UID: {entry.UserData.UID}\nVisible: {entry.IsVisible}\nOnline: {entry.IsOnline}\nDirect pair: {entry.IsDirectlyPaired}"); + } + + ImGui.TableNextColumn(); + ImGui.TextUnformatted(entry.IsVisible ? "Visible" : entry.IsOnline ? "Online" : "Offline"); + } + + ImGui.EndTable(); + } + } + ImGui.EndChild(); + + ImGui.SameLine(); + + if (ImGui.BeginChild("##pairDebugDetails", new Vector2(0, 0), true, ImGuiWindowFlags.HorizontalScrollbar)) + { + DrawPairDebugDetails(selectedPair, snapshot); + } + ImGui.EndChild(); + } + ImGui.EndChild(); + } + + private static bool PairMatchesFilter(Pair pair, string filter) + { + if (string.IsNullOrWhiteSpace(filter)) + { + return true; + } + + return pair.UserData.UID.Contains(filter, StringComparison.OrdinalIgnoreCase) + || pair.UserData.AliasOrUID.Contains(filter, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrEmpty(pair.PlayerName) && pair.PlayerName.Contains(filter, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(pair.Ident) && pair.Ident.Contains(filter, StringComparison.OrdinalIgnoreCase)); + } + + private static void DrawPairStateIndicator(Pair pair) + { + var color = pair.IsVisible + ? UIColors.Get("LightlessGreen") + : pair.IsOnline ? UIColors.Get("LightlessYellow") + : UIColors.Get("DimRed"); + + var drawList = ImGui.GetWindowDrawList(); + var cursor = ImGui.GetCursorScreenPos(); + var radius = ImGui.GetTextLineHeight() * 0.35f; + var center = cursor + new Vector2(radius, radius); + drawList.AddCircleFilled(center, radius, ImGui.ColorConvertFloat4ToU32(color)); + ImGui.Dummy(new Vector2(radius * 2f, radius * 2f)); + } + + private void DrawPairDebugDetails(Pair pair, PairUiSnapshot snapshot) + { + var debugInfo = pair.GetDebugInfo(); + var statusColor = pair.IsVisible + ? UIColors.Get("LightlessGreen") + : pair.IsOnline ? UIColors.Get("LightlessYellow") + : UIColors.Get("DimRed"); + + ImGui.TextColored(statusColor, pair.UserData.AliasOrUID); + ImGui.SameLine(); + ImGui.TextColored(statusColor, $"[{(pair.IsVisible ? "Visible" : pair.IsOnline ? "Online" : "Offline")}]"); + + if (ImGui.BeginTable("##pairDebugProperties", 2, ImGuiTableFlags.SizingStretchProp)) + { + DrawPairPropertyRow("UID", pair.UserData.UID); + DrawPairPropertyRow("Alias", string.IsNullOrEmpty(pair.UserData.Alias) ? "(none)" : pair.UserData.Alias!); + DrawPairPropertyRow("Player Name", pair.PlayerName ?? "(not cached)"); + DrawPairPropertyRow("Handler Ident", string.IsNullOrEmpty(pair.Ident) ? "(not bound)" : pair.Ident); + DrawPairPropertyRow("Character Id", FormatCharacterId(pair.PlayerCharacterId)); + DrawPairPropertyRow("Direct Pair", FormatBool(pair.IsDirectlyPaired)); + DrawPairPropertyRow("Individual Status", pair.IndividualPairStatus.ToString()); + DrawPairPropertyRow("Any Connection", FormatBool(pair.HasAnyConnection())); + DrawPairPropertyRow("Paused", FormatBool(pair.IsPaused)); + DrawPairPropertyRow("Visible", FormatBool(pair.IsVisible), statusColor); + DrawPairPropertyRow("Online", FormatBool(pair.IsOnline)); + DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); + DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); + DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); + DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); + DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); + ImGui.EndTable(); + } + + ImGui.Separator(); + ImGui.TextUnformatted("Applied Data"); + if (ImGui.BeginTable("##pairDebugDataStats", 2, ImGuiTableFlags.SizingStretchProp)) + { + DrawPairPropertyRow("Last Data Size", FormatBytes(pair.LastAppliedDataBytes)); + DrawPairPropertyRow("Approx. VRAM", FormatBytes(pair.LastAppliedApproximateVRAMBytes)); + DrawPairPropertyRow("Effective VRAM", FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes)); + DrawPairPropertyRow("Last Triangles", pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture)); + ImGui.EndTable(); + } + + var lastData = pair.LastReceivedCharacterData; + if (lastData is null) + { + ImGui.TextDisabled("No character data has been received for this pair."); + } + else + { + var fileReplacementCount = lastData.FileReplacements.Values.Sum(list => list?.Count ?? 0); + var totalGamePaths = lastData.FileReplacements.Values.Sum(list => list?.Sum(replacement => replacement.GamePaths.Length) ?? 0); + ImGui.BulletText($"File replacements: {fileReplacementCount} entries across {totalGamePaths} game paths."); + ImGui.BulletText($"Customize+: {lastData.CustomizePlusData.Count}, Glamourer entries: {lastData.GlamourerData.Count}"); + ImGui.BulletText($"Manipulation length: {lastData.ManipulationData.Length}, Heels set: {FormatBool(!string.IsNullOrEmpty(lastData.HeelsData))}"); + + if (ImGui.TreeNode("Last Received Character Data (JSON)")) + { + DrawJsonBlob(lastData); + ImGui.TreePop(); + } + } + + ImGui.Separator(); + ImGui.TextUnformatted("Application Timeline"); + if (ImGui.BeginTable("##pairDebugTimeline", 2, ImGuiTableFlags.SizingStretchProp)) + { + DrawPairPropertyRow("Last Data Received", FormatTimestamp(debugInfo.LastDataReceivedAt)); + DrawPairPropertyRow("Last Apply Attempt", FormatTimestamp(debugInfo.LastApplyAttemptAt)); + DrawPairPropertyRow("Last Successful Apply", FormatTimestamp(debugInfo.LastSuccessfulApplyAt)); + ImGui.EndTable(); + } + + if (!string.IsNullOrEmpty(debugInfo.LastFailureReason)) + { + UiSharedService.ColorTextWrapped($"Last failure: {debugInfo.LastFailureReason}", UIColors.Get("DimRed")); + if (debugInfo.BlockingConditions.Count > 0) + { + ImGui.TextUnformatted("Blocking conditions:"); + foreach (var condition in debugInfo.BlockingConditions) + { + ImGui.BulletText(condition); + } + } + } + + ImGui.Separator(); + ImGui.TextUnformatted("Application & Download State"); + if (ImGui.BeginTable("##pairDebugProcessing", 2, ImGuiTableFlags.SizingStretchProp)) + { + DrawPairPropertyRow("Applying Data", FormatBool(debugInfo.IsApplying)); + DrawPairPropertyRow("Downloading", FormatBool(debugInfo.IsDownloading)); + DrawPairPropertyRow("Pending Downloads", debugInfo.PendingDownloadCount.ToString(CultureInfo.InvariantCulture)); + DrawPairPropertyRow("Forbidden Downloads", debugInfo.ForbiddenDownloadCount.ToString(CultureInfo.InvariantCulture)); + ImGui.EndTable(); + } + + ImGui.Separator(); + ImGui.TextUnformatted("Syncshell Memberships"); + if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0) + { + foreach (var group in groups.OrderBy(g => g.Group.AliasOrGID, StringComparer.OrdinalIgnoreCase)) + { + var flags = group.GroupPairUserInfos.TryGetValue(pair.UserData.UID, out var info) ? info : GroupPairUserInfo.None; + var flagLabel = flags switch + { + GroupPairUserInfo.None => string.Empty, + _ => $" ({string.Join(", ", GetGroupInfoFlags(flags))})" + }; + ImGui.BulletText($"{group.Group.AliasOrGID} [{group.Group.GID}]{flagLabel}"); + } + } + else + { + ImGui.TextDisabled("Not a member of any syncshells."); + } + + if (pair.UserPair is null) + { + ImGui.TextDisabled("Pair DTO snapshot unavailable."); + } + else if (ImGui.TreeNode("Pair DTO Snapshot")) + { + DrawJsonBlob(pair.UserPair); + ImGui.TreePop(); + } + + ImGui.Separator(); + DrawPairEventLog(pair); + } + + private static IEnumerable GetGroupInfoFlags(GroupPairUserInfo info) + { + if (info.HasFlag(GroupPairUserInfo.IsModerator)) + { + yield return "Moderator"; + } + + if (info.HasFlag(GroupPairUserInfo.IsPinned)) + { + yield return "Pinned"; + } + } + + private void DrawPairEventLog(Pair pair) + { + ImGui.TextUnformatted("Recent Events"); + var events = _eventAggregator.EventList.Value; + var alias = pair.UserData.Alias; + var aliasOrUid = pair.UserData.AliasOrUID; + var rawUid = pair.UserData.UID; + var playerName = pair.PlayerName; + + var relevantEvents = events.Where(e => + EventMatchesIdentifier(e, rawUid) + || EventMatchesIdentifier(e, aliasOrUid) + || EventMatchesIdentifier(e, alias) + || (!string.IsNullOrEmpty(playerName) && string.Equals(e.Character, playerName, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(e => e.EventTime) + .Take(40) + .ToList(); + + if (relevantEvents.Count == 0) + { + ImGui.TextDisabled("No recent events were logged for this pair."); + return; + } + + var baseTableHeight = 300f * ImGuiHelpers.GlobalScale; + var tableHeight = MathF.Max(baseTableHeight, ImGui.GetContentRegionAvail().Y); + if (ImGui.BeginTable("##pairDebugEvents", 3, + ImGuiTableFlags.RowBg | ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.ScrollX | ImGuiTableFlags.ScrollY, + new Vector2(0f, tableHeight))) + { + ImGui.TableSetupScrollFreeze(0, 1); + ImGui.TableSetupColumn("Time", ImGuiTableColumnFlags.WidthFixed, 110f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Type", ImGuiTableColumnFlags.WidthFixed, 60f * ImGuiHelpers.GlobalScale); + ImGui.TableSetupColumn("Details"); + ImGui.TableHeadersRow(); + + foreach (var ev in relevantEvents) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(ev.EventTime.ToString("T", CultureInfo.CurrentCulture)); + + ImGui.TableNextColumn(); + var (icon, color) = ev.EventSeverity switch + { + EventSeverity.Informational => (FontAwesomeIcon.InfoCircle, UIColors.Get("LightlessGreen")), + EventSeverity.Warning => (FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")), + EventSeverity.Error => (FontAwesomeIcon.ExclamationCircle, UIColors.Get("DimRed")), + _ => (FontAwesomeIcon.QuestionCircle, UIColors.Get("LightlessGrey")) + }; + _uiShared.IconText(icon, color); + UiSharedService.AttachToolTip(ev.EventSeverity.ToString()); + + ImGui.TableNextColumn(); + ImGui.TextWrapped($"[{ev.EventSource}] {ev.Message}"); + } + + ImGui.EndTable(); + } + } + + private static bool EventMatchesIdentifier(Event evt, string? identifier) + { + if (string.IsNullOrWhiteSpace(identifier)) + { + return false; + } + + return (!string.IsNullOrEmpty(evt.UserId) && string.Equals(evt.UserId, identifier, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(evt.AliasOrUid) && string.Equals(evt.AliasOrUid, identifier, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrEmpty(evt.UID) && string.Equals(evt.UID, identifier, StringComparison.OrdinalIgnoreCase)); + } + + private static void DrawJsonBlob(object? value) + { + if (value is null) + { + ImGui.TextDisabled("(null)"); + return; + } + + try + { + var json = JsonSerializer.Serialize(value, DebugJsonOptions); + ImGui.PushStyleColor(ImGuiCol.Text, UIColors.Get("LightlessGrey")); + foreach (var line in json.Split('\n')) + { + ImGui.TextUnformatted(line); + } + ImGui.PopStyleColor(); + } + catch (Exception ex) + { + UiSharedService.ColorTextWrapped($"Failed to serialize data: {ex.Message}", UIColors.Get("DimRed")); + } + } + + private static void DrawPairPropertyRow(string label, string value, Vector4? colorOverride = null) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.TextUnformatted(label); + ImGui.TableNextColumn(); + if (colorOverride is { } color) + { + ImGui.TextColored(color, value); + } + else + { + ImGui.TextUnformatted(value); + } + } + + private static string FormatTimestamp(DateTime? value) + { + return value is null ? "n/a" : value.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture); + } + + private static string FormatBytes(long value) => value < 0 ? "n/a" : UiSharedService.ByteToString(value); + + private static string FormatCharacterId(uint id) => id == uint.MaxValue ? "n/a" : $"{id} (0x{id:X8})"; + + private static string FormatBool(bool value) => value ? "Yes" : "No"; + + private void DrawFileStorageSettings() { _lastTab = "FileCache"; @@ -2092,11 +2485,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { // redo } - else - { - _lightfinderIconInputInitialized = false; - _lightfinderIconPresetIndex = -1; - } } _uiShared.DrawHelpText("Switch between the Lightfinder text label and an icon on nameplates."); @@ -2105,11 +2493,6 @@ public class SettingsUi : WindowMediatorSubscriberBase { //redo } - else - { - _lightfinderIconInputInitialized = false; - _lightfinderIconPresetIndex = -1; - } UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurple"), 1.5f); ImGui.TreePop(); diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index eb694aa..684caef 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -42,6 +42,8 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase private const float DescriptionMaxVisibleLines = 12f; private const string UserDescriptionPlaceholder = "-- User has no description set --"; private const string GroupDescriptionPlaceholder = "-- Syncshell has no description set --"; + private const string LightfinderDisplayName = "Lightfinder User"; + private readonly string _lightfinderDisplayName = LightfinderDisplayName; private float _lastComputedWindowHeight = -1f; public StandaloneProfileUi( @@ -50,6 +52,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase UiSharedService uiBuilder, ServerConfigurationManager serverManager, ProfileTagService profileTagService, + DalamudUtilService dalamudUtilService, LightlessProfileManager lightlessProfileManager, PairUiService pairUiService, Pair? pair, @@ -58,7 +61,12 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase bool isLightfinderContext, string? lightfinderCid, PerformanceCollectorService performanceCollector) - : base(logger, mediator, BuildWindowTitle(userData, groupData, isLightfinderContext), performanceCollector) + : base(logger, mediator, BuildWindowTitle( + userData, + groupData, + isLightfinderContext, + isLightfinderContext ? ResolveLightfinderDisplayName(dalamudUtilService, lightfinderCid) : null), + performanceCollector) { _uiSharedService = uiBuilder; _serverManager = serverManager; @@ -71,17 +79,19 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase _isGroupProfile = groupData is not null; _isLightfinderContext = isLightfinderContext; _lightfinderCid = lightfinderCid; + if (_isLightfinderContext) + _lightfinderDisplayName = ResolveLightfinderDisplayName(dalamudUtilService, lightfinderCid); Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoResize; var fixedSize = new Vector2(840f, 525f) * ImGuiHelpers.GlobalScale; Size = fixedSize; SizeCondition = ImGuiCond.Always; - SizeConstraints = new() - { - MinimumSize = fixedSize, - MaximumSize = new Vector2(fixedSize.X, fixedSize.Y * MaxHeightMultiplier) - }; + WindowBuilder.For(this) + .SetSizeConstraints( + fixedSize, + new Vector2(fixedSize.X, fixedSize.Y * MaxHeightMultiplier)) + .Apply(); IsOpen = true; } @@ -115,7 +125,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase return fallback; } - private static string BuildWindowTitle(UserData? userData, GroupData? groupData, bool isLightfinderContext) + private static string BuildWindowTitle(UserData? userData, GroupData? groupData, bool isLightfinderContext, string? lightfinderDisplayName) { if (groupData is not null) { @@ -126,11 +136,24 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase if (userData is null) return "Lightless Profile##LightlessSyncStandaloneProfileUI"; - var name = userData.AliasOrUID; + var name = isLightfinderContext ? lightfinderDisplayName ?? LightfinderDisplayName : userData.AliasOrUID; var suffix = isLightfinderContext ? " (Lightfinder)" : string.Empty; return $"Lightless Profile of {name}{suffix}##LightlessSyncStandaloneProfileUI{name}"; } + private static string ResolveLightfinderDisplayName(DalamudUtilService dalamudUtilService, string? hashedCid) + { + if (string.IsNullOrEmpty(hashedCid)) + return LightfinderDisplayName; + + var (name, address) = dalamudUtilService.FindPlayerByNameHash(hashedCid); + if (string.IsNullOrEmpty(name)) + return LightfinderDisplayName; + + var world = dalamudUtilService.GetWorldNameFromPlayerAddress(address); + return string.IsNullOrEmpty(world) ? name : $"{name} ({world})"; + } + protected override void DrawInternal() { try @@ -300,7 +323,7 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase ? Pair.UserData.Alias! : _isLightfinderContext ? "Lightfinder Session" : noteText ?? string.Empty; - bool hasVanityAlias = userData.HasVanity && !string.IsNullOrWhiteSpace(userData.Alias); + bool hasVanityAlias = !_isLightfinderContext && userData.HasVanity && !string.IsNullOrWhiteSpace(userData.Alias); Vector4? vanityTextColor = null; Vector4? vanityGlowColor = null; @@ -314,10 +337,12 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase } bool useVanityColors = vanityTextColor.HasValue || vanityGlowColor.HasValue; - string primaryHeaderText = hasVanityAlias ? userData.Alias! : userData.UID; + string primaryHeaderText = _isLightfinderContext + ? _lightfinderDisplayName + : hasVanityAlias ? userData.Alias! : userData.UID; List<(string Text, bool UseVanityColor, bool Disabled)> secondaryHeaderLines = new(); - if (hasVanityAlias) + if (!_isLightfinderContext && hasVanityAlias) { secondaryHeaderLines.Add((userData.UID, useVanityColors, false)); @@ -1232,4 +1257,4 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase bool Emphasis, IReadOnlyList? Tooltip = null, string? TooltipTitle = null); -} \ No newline at end of file +} diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 310d79e..7374203 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -60,11 +60,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase _dalamudUtilService = dalamudUtilService; IsOpen = false; - SizeConstraints = new() - { - MinimumSize = new(600, 400), - MaximumSize = new(600, 550) - }; + WindowBuilder.For(this) + .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) + .Apply(); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index dabe8c0..16f3ea0 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -7,17 +7,14 @@ using Dalamud.Utility; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Mediator; +using LightlessSync.Services.LightFinder; using LightlessSync.Utils; using LightlessSync.UI.Models; using LightlessSync.UI.Style; using LightlessSync.WebAPI; using System.Numerics; -using System.Threading.Tasks; -using System.Linq; - namespace LightlessSync.UI; @@ -29,6 +26,8 @@ public class TopTabMenu private readonly PairRequestService _pairRequestService; private readonly DalamudUtilService _dalamudUtilService; + private readonly LightFinderService _lightFinderService; + private readonly LightFinderScannerService _lightFinderScannerService; private readonly HashSet _pendingPairRequestActions = new(StringComparer.Ordinal); private bool _pairRequestsExpanded; // useless for now private int _lastRequestCount; @@ -42,7 +41,7 @@ public class TopTabMenu private SelectedTab _selectedTab = SelectedTab.None; private PairUiSnapshot? _currentSnapshot; - public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService) + public TopTabMenu(LightlessMediator lightlessMediator, ApiController apiController, UiSharedService uiSharedService, PairRequestService pairRequestService, DalamudUtilService dalamudUtilService, NotificationService lightlessNotificationService, LightFinderService lightFinderService, LightFinderScannerService lightFinderScannerService) { _lightlessMediator = lightlessMediator; _apiController = apiController; @@ -50,6 +49,8 @@ public class TopTabMenu _dalamudUtilService = dalamudUtilService; _uiSharedService = uiSharedService; _lightlessNotificationService = lightlessNotificationService; + _lightFinderService = lightFinderService; + _lightFinderScannerService = lightFinderScannerService; } private enum SelectedTab @@ -154,7 +155,7 @@ public class TopTabMenu Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); } } - UiSharedService.AttachToolTip("Zone Chat"); + UiSharedService.AttachToolTip("Lightless Chat"); ImGui.SameLine(); ImGui.SameLine(); @@ -786,12 +787,28 @@ public class TopTabMenu ImGui.SameLine(); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, "Syncshell Finder", buttonX, center: true)) + var syncshellFinderLabel = GetSyncshellFinderLabel(); + if (_uiSharedService.IconTextButton(FontAwesomeIcon.Globe, syncshellFinderLabel, buttonX, center: true)) { _lightlessMediator.Publish(new UiToggleMessage(typeof(SyncshellFinderUI))); } } + private string GetSyncshellFinderLabel() + { + if (!_lightFinderService.IsBroadcasting) + return "Syncshell Finder"; + + var nearbyCount = _lightFinderScannerService + .GetActiveSyncshellBroadcasts() + .Where(b => !string.IsNullOrEmpty(b.GID)) + .Select(b => b.GID!) + .Distinct(StringComparer.Ordinal) + .Count(); + + return nearbyCount > 0 ? $"Syncshell Finder ({nearbyCount})" : "Syncshell Finder"; + } + private void DrawUserConfig(float availableWidth, float spacingX) { var buttonX = (availableWidth - spacingX) / 2f; diff --git a/LightlessSync/UI/UIColors.cs b/LightlessSync/UI/UIColors.cs index 90911d7..9d7f770 100644 --- a/LightlessSync/UI/UIColors.cs +++ b/LightlessSync/UI/UIColors.cs @@ -19,6 +19,7 @@ namespace LightlessSync.UI { "LightlessGreen", "#7cd68a" }, { "LightlessGreenDefault", "#468a50" }, { "LightlessOrange", "#ffb366" }, + { "LightlessGrey", "#8f8f8f" }, { "PairBlue", "#88a2db" }, { "DimRed", "#d44444" }, { "LightlessAdminText", "#ffd663" }, diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index 5fb2480..c5331a0 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -13,6 +13,7 @@ using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using Dalamud.Interface; using LightlessSync.UI.Models; +using LightlessSync.Utils; namespace LightlessSync.UI; @@ -69,21 +70,20 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase _uiShared = uiShared; _configService = configService; - AllowClickthrough = false; - AllowPinning = false; RespectCloseHotkey = true; ShowCloseButton = true; Flags = ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoMove; - SizeConstraints = new WindowSizeConstraints() - { - MinimumSize = new Vector2(800, 700), MaximumSize = new Vector2(800, 700), - }; - PositionCondition = ImGuiCond.Always; + WindowBuilder.For(this) + .AllowPinning(false) + .AllowClickthrough(false) + .SetFixedSize(new Vector2(800, 700)) + .Apply(); + LoadEmbeddedResources(); logger.LogInformation("UpdateNotesUi constructor completed successfully"); } diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 084b55b..93edc5d 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1,9 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; +using System.Globalization; using System.Numerics; -using System.Threading.Tasks; using LightlessSync.API.Data; using Dalamud.Bindings.ImGui; using Dalamud.Interface; @@ -13,7 +9,6 @@ using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Dto.Chat; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; -using LightlessSync.PlayerData.Pairs; using LightlessSync.Services; using LightlessSync.Services.Chat; using LightlessSync.Services.Mediator; @@ -75,7 +70,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ChatConfigService chatConfigService, ApiController apiController, PerformanceCollectorService performanceCollectorService) - : base(logger, mediator, "Zone Chat", performanceCollectorService) + : base(logger, mediator, "Lightless Chat", performanceCollectorService) { _uiSharedService = uiSharedService; _zoneChatService = zoneChatService; @@ -93,11 +88,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase RefreshWindowFlags(); Size = new Vector2(450, 420) * ImGuiHelpers.GlobalScale; SizeCondition = ImGuiCond.FirstUseEver; - SizeConstraints = new() - { - MinimumSize = new Vector2(320f, 260f) * ImGuiHelpers.GlobalScale, - MaximumSize = new Vector2(900f, 900f) * ImGuiHelpers.GlobalScale - }; + WindowBuilder.For(this) + .SetSizeConstraints( + new Vector2(320f, 260f) * ImGuiHelpers.GlobalScale, + new Vector2(900f, 900f) * ImGuiHelpers.GlobalScale) + .Apply(); Mediator.Subscribe(this, OnChatChannelMessageAdded); Mediator.Subscribe(this, msg => diff --git a/LightlessSync/Utils/WindowUtils.cs b/LightlessSync/Utils/WindowUtils.cs new file mode 100644 index 0000000..fb88a84 --- /dev/null +++ b/LightlessSync/Utils/WindowUtils.cs @@ -0,0 +1,139 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Windowing; +using LightlessSync.UI; +using LightlessSync.WebAPI.SignalR.Utils; +using System.Numerics; + +namespace LightlessSync.Utils; + +public sealed class WindowBuilder +{ + private readonly Window _window; + private readonly List _titleButtons = new(); + + private WindowBuilder(Window window) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + } + + public static WindowBuilder For(Window window) => new(window); + + public WindowBuilder AllowPinning(bool allow = true) + { + _window.AllowPinning = allow; + return this; + } + + public WindowBuilder AllowClickthrough(bool allow = true) + { + _window.AllowClickthrough = allow; + return this; + } + + public WindowBuilder SetFixedSize(Vector2 size) => SetSizeConstraints(size, size); + + public WindowBuilder SetSizeConstraints(Vector2 min, Vector2 max) + { + _window.SizeConstraints = new Window.WindowSizeConstraints + { + MinimumSize = min, + MaximumSize = max, + }; + return this; + } + + public WindowBuilder AddFlags(ImGuiWindowFlags flags) + { + _window.Flags |= flags; + return this; + } + + public WindowBuilder AddTitleBarButton(FontAwesomeIcon icon, string tooltip, Action onClick, Vector2? iconOffset = null) + { + _titleButtons.Add(new Window.TitleBarButton + { + Icon = icon, + IconOffset = iconOffset ?? new Vector2(2, 1), + Click = _ => onClick(), + ShowTooltip = () => UiSharedService.AttachToolTip(tooltip), + }); + return this; + } + + public Window Apply() + { + if (_titleButtons.Count > 0) + _window.TitleBarButtons = _titleButtons; + return _window; + } +} + +public static class WindowUtils +{ + public static Vector4 GetUidColor(this ServerState state) + { + return state switch + { + ServerState.Connecting => UIColors.Get("LightlessYellow"), + ServerState.Reconnecting => UIColors.Get("DimRed"), + ServerState.Connected => UIColors.Get("LightlessPurple"), + ServerState.Disconnected => UIColors.Get("LightlessYellow"), + ServerState.Disconnecting => UIColors.Get("LightlessYellow"), + ServerState.Unauthorized => UIColors.Get("DimRed"), + ServerState.VersionMisMatch => UIColors.Get("DimRed"), + ServerState.Offline => UIColors.Get("DimRed"), + ServerState.RateLimited => UIColors.Get("LightlessYellow"), + ServerState.NoSecretKey => UIColors.Get("LightlessYellow"), + ServerState.MultiChara => UIColors.Get("LightlessYellow"), + ServerState.OAuthMisconfigured => UIColors.Get("DimRed"), + ServerState.OAuthLoginTokenStale => UIColors.Get("DimRed"), + ServerState.NoAutoLogon => UIColors.Get("LightlessYellow"), + _ => UIColors.Get("DimRed"), + }; + } + + public static string GetUidText(this ServerState state, string displayName) + { + return state switch + { + ServerState.Reconnecting => "Reconnecting", + ServerState.Connecting => "Connecting", + ServerState.Disconnected => "Disconnected", + ServerState.Disconnecting => "Disconnecting", + ServerState.Unauthorized => "Unauthorized", + ServerState.VersionMisMatch => "Version mismatch", + ServerState.Offline => "Unavailable", + ServerState.RateLimited => "Rate Limited", + ServerState.NoSecretKey => "No Secret Key", + ServerState.MultiChara => "Duplicate Characters", + ServerState.OAuthMisconfigured => "Misconfigured OAuth2", + ServerState.OAuthLoginTokenStale => "Stale OAuth2", + ServerState.NoAutoLogon => "Auto Login disabled", + ServerState.Connected => displayName, + _ => string.Empty, + }; + } + + public static string GetServerError(this ServerState state, string authFailureMessage) + { + return state switch + { + ServerState.Connecting => "Attempting to connect to the server.", + ServerState.Reconnecting => "Connection to server interrupted, attempting to reconnect to the server.", + ServerState.Disconnected => "You are currently disconnected from the Lightless Sync server.", + ServerState.Disconnecting => "Disconnecting from the server", + ServerState.Unauthorized => "Server Response: " + authFailureMessage, + ServerState.Offline => "Your selected Lightless Sync server is currently offline.", + ServerState.VersionMisMatch => + "Your plugin or the server you are connecting to is out of date. Please update your plugin now. If you already did so, contact the server provider to update their server to the latest version.", + ServerState.RateLimited => "You are rate limited for (re)connecting too often. Disconnect, wait 10 minutes and try again.", + ServerState.NoSecretKey => "You have no secret key set for this current character. Open Settings -> Service Settings and set a secret key for the current character. You can reuse the same secret key for multiple characters.", + ServerState.MultiChara => "Your Character Configuration has multiple characters configured with same name and world. You will not be able to connect until you fix this issue. Remove the duplicates from the configuration in Settings -> Service Settings -> Character Management and reconnect manually after.", + ServerState.OAuthMisconfigured => "OAuth2 is enabled but not fully configured, verify in the Settings -> Service Settings that you have OAuth2 connected and, importantly, a UID assigned to your current character.", + ServerState.OAuthLoginTokenStale => "Your OAuth2 login token is stale and cannot be used to renew. Go to the Settings -> Service Settings and unlink then relink your OAuth2 configuration.", + ServerState.NoAutoLogon => "This character has automatic login into Lightless disabled. Press the connect button to connect to Lightless.", + _ => string.Empty, + }; + } +} diff --git a/OtterGui b/OtterGui index 18e62ab..1459e2b 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 18e62ab2d8b9ac7028a33707eb35f8f9c61f245a +Subproject commit 1459e2b8f5e1687f659836709e23571235d4206c diff --git a/Penumbra.Api b/Penumbra.Api index 704d62f..d520712 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 704d62f64f791b8cfd42363beaa464ad6f98ae48 +Subproject commit d52071290b48a1f2292023675b4b72365aef4cc0 diff --git a/Penumbra.String b/Penumbra.String index 4aac62e..462afac 160000 --- a/Penumbra.String +++ b/Penumbra.String @@ -1 +1 @@ -Subproject commit 4aac62e73b89a0c538a7a0a5c22822f15b13c0cc +Subproject commit 462afac558becebbe06b4e5be9b1b3c3f5a9b6d6 -- 2.49.1 From d5c11cd22f704901f2ce4b8f21206406f0699f84 Mon Sep 17 00:00:00 2001 From: cake Date: Mon, 15 Dec 2025 23:52:06 +0100 Subject: [PATCH 094/140] Added tags in the shell finder, button is red when not joinable. Fixed some null errors. --- LightlessSync/UI/LightFinderUI.cs | 1 + LightlessSync/UI/SyncshellFinderUI.cs | 187 +++++++++++++++++++++++--- 2 files changed, 170 insertions(+), 18 deletions(-) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index fa88475..ca74bc9 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -2,6 +2,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; +using Dalamud.Utility; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 7374203..00a008f 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -1,6 +1,7 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Colors; +using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using LightlessSync.API.Data; @@ -8,14 +9,16 @@ using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; +using LightlessSync.API.Dto.User; using LightlessSync.Services; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Tags; using LightlessSync.Utils; using LightlessSync.WebAPI; -using LightlessSync.UI.Services; using Microsoft.Extensions.Logging; using System.Numerics; -using LightlessSync.Services.LightFinder; namespace LightlessSync.UI; @@ -28,6 +31,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private readonly PairUiService _pairUiService; private readonly DalamudUtilService _dalamudUtilService; + private Vector4 _tagBackgroundColor = new(0.18f, 0.18f, 0.18f, 0.95f); + private Vector4 _tagBorderColor = new(0.35f, 0.35f, 0.35f, 0.4f); + + private readonly List _seResolvedSegments = new(); private readonly List _nearbySyncshells = []; private List _currentSyncshells = []; private int _selectedNearbyIndex = -1; @@ -40,6 +47,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase private bool _useTestSyncshells = false; private bool _compactView = false; + private readonly LightlessProfileManager _lightlessProfileManager; public SyncshellFinderUI( ILogger logger, @@ -50,7 +58,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ApiController apiController, LightFinderScannerService broadcastScannerService, PairUiService pairUiService, - DalamudUtilService dalamudUtilService) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) + DalamudUtilService dalamudUtilService, + LightlessProfileManager lightlessProfileManager) : base(logger, mediator, "Shellfinder###LightlessSyncshellFinderUI", performanceCollectorService) { _broadcastService = broadcastService; _uiSharedService = uiShared; @@ -58,16 +67,18 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase _broadcastScannerService = broadcastScannerService; _pairUiService = pairUiService; _dalamudUtilService = dalamudUtilService; + _lightlessProfileManager = lightlessProfileManager; IsOpen = false; WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) - .Apply(); + .SetSizeConstraints(new Vector2(600, 400), new Vector2(600, 550)) + .Apply(); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); + } public override async void OnOpen() @@ -80,7 +91,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase { ImGui.BeginGroup(); _uiSharedService.MediumText("Nearby Syncshells", UIColors.Get("LightlessPurple")); - + #if DEBUG if (ImGui.SmallButton("Show test syncshells")) { @@ -92,7 +103,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase string checkboxLabel = "Compact view"; float availWidth = ImGui.GetContentRegionAvail().X; - float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); + float checkboxWidth = ImGui.CalcTextSize(checkboxLabel).X + ImGui.GetFrameHeight(); float rightX = ImGui.GetCursorPosX() + availWidth - checkboxWidth - 4.0f; ImGui.SetCursorPosX(rightX); @@ -130,13 +141,17 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() ?? []; + var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts(); foreach (var shell in _nearbySyncshells) { string broadcasterName; + if (shell?.Group == null || string.IsNullOrEmpty(shell.Group.GID)) + continue; + if (_useTestSyncshells) { var displayName = !string.IsNullOrEmpty(shell.Group.Alias) @@ -206,7 +221,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase var (shell, broadcasterName) = listData[index]; ImGui.PushID(shell.Group.GID); - float rowHeight = 90f * ImGuiHelpers.GlobalScale; + float rowHeight = 74f * ImGuiHelpers.GlobalScale; ImGui.BeginChild($"ShellRow##{shell.Group.GID}", new Vector2(-1, rowHeight), border: true); @@ -234,10 +249,48 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); - ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); + var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); + IReadOnlyList groupTags = + groupProfile != null && groupProfile.Tags.Count > 0 + ? ProfileTagService.ResolveTags(groupProfile.Tags) + : []; + + var limitedTags = groupTags.Count > 3 + ? [.. groupTags.Take(3)] + : groupTags; + + float tagScale = ImGuiHelpers.GlobalScale * 0.9f; + + Vector2 rowStartLocal = ImGui.GetCursorPos(); + + float tagsWidth = 0f; + float tagsHeight = 0f; + + if (limitedTags.Count > 0) + { + (tagsWidth, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); + } + else + { + ImGui.SetCursorPosX(startX); + ImGui.TextDisabled("-- No tags set --"); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + + float btnBaselineY = rowStartLocal.Y; + float joinX = rowStartLocal.X + (tagsWidth > 0 ? tagsWidth + style.ItemSpacing.X : 0f); + + ImGui.SetCursorPos(new Vector2(joinX, btnBaselineY)); DrawJoinButton(shell); + float btnHeight = ImGui.GetFrameHeightWithSpacing(); + float rowHeightUsed = MathF.Max(tagsHeight, btnHeight); + + ImGui.SetCursorPos(new Vector2( + rowStartLocal.X, + rowStartLocal.Y + rowHeightUsed)); + ImGui.EndChild(); ImGui.PopID(); @@ -311,10 +364,39 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGui.SetTooltip("Broadcaster of the syncshell."); ImGui.EndGroup(); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); ImGui.Dummy(new Vector2(0, 6 * ImGuiHelpers.GlobalScale)); + var groupProfile = _lightlessProfileManager.GetLightlessGroupProfile(shell.Group); + + IReadOnlyList groupTags = + groupProfile != null && groupProfile.Tags.Count > 0 + ? ProfileTagService.ResolveTags(groupProfile.Tags) + : []; + + float tagScale = ImGuiHelpers.GlobalScale * 0.9f; + + if (groupTags.Count > 0) + { + var limitedTags = groupTags.Count > 2 + ? [.. groupTags.Take(2)] + : groupTags; + + ImGui.SetCursorPosX(startX); + + var (_, tagsHeight) = RenderProfileTagsSingleRow(limitedTags, tagScale); + + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + else + { + ImGui.SetCursorPosX(startX); + ImGui.TextDisabled("-- No tags set --"); + ImGui.Dummy(new Vector2(0, 4 * ImGuiHelpers.GlobalScale)); + } + var buttonHeight = ImGui.GetFrameHeightWithSpacing(); var remainingY = ImGui.GetContentRegionAvail().Y - buttonHeight; if (remainingY > 0) @@ -338,7 +420,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase { if (totalPages > 1) { - UiSharedService.ColoredSeparator(UIColors.Get("PairBlue")); + UiSharedService.ColoredSeparator(UIColors.Get("LightlessPurpleDefault")); var style = ImGui.GetStyle(); string pageLabel = $"Page {_syncshellPageIndex + 1}/{totalPages}"; @@ -371,10 +453,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase const string visibleLabel = "Join"; var label = $"{visibleLabel}##{shell.Group.GID}"; - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); - var isAlreadyMember = _currentSyncshells.Exists(g => string.Equals(g.GID, shell.GID, StringComparison.Ordinal)); var isRecentlyJoined = _recentlyJoined.Contains(shell.GID); @@ -386,7 +464,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase var textSize = ImGui.CalcTextSize(visibleLabel); var width = textSize.X + style.FramePadding.X * 20f; - buttonSize = new Vector2(width, 0); + buttonSize = new Vector2(width, 30f); float availX = ImGui.GetContentRegionAvail().X; float curX = ImGui.GetCursorPosX(); @@ -400,6 +478,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase if (!isAlreadyMember && !isRecentlyJoined) { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("LightlessGreen")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessGreen").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessGreen").WithAlpha(0.75f)); if (ImGui.Button(label, buttonSize)) { _logger.LogInformation($"Join requested for Syncshell {shell.Group.GID} ({shell.Group.Alias})"); @@ -436,6 +517,10 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase } else { + ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("DimRed")); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("DimRed").WithAlpha(0.85f)); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("DimRed").WithAlpha(0.75f)); + using (ImRaii.Disabled()) { ImGui.Button(label, buttonSize); @@ -446,6 +531,72 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ImGui.PopStyleColor(3); } + + private (float widthUsed, float rowHeight) RenderProfileTagsSingleRow(IReadOnlyList tags, float scale) + { + if (tags == null || tags.Count == 0) + return (0f, 0f); + + var drawList = ImGui.GetWindowDrawList(); + var style = ImGui.GetStyle(); + var defaultTextColorU32 = ImGui.GetColorU32(ImGuiCol.Text); + + var baseLocal = ImGui.GetCursorPos(); + var baseScreen = ImGui.GetCursorScreenPos(); + float availableWidth = ImGui.GetContentRegionAvail().X; + if (availableWidth <= 0f) + availableWidth = 1f; + + float cursorLocalX = baseLocal.X; + float cursorScreenX = baseScreen.X; + float rowHeight = 0f; + + for (int i = 0; i < tags.Count; i++) + { + var tag = tags[i]; + if (!tag.HasContent) + continue; + + var tagSize = ProfileTagRenderer.MeasureTag(tag, scale, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + float tagWidth = tagSize.X; + float tagHeight = tagSize.Y; + + if (cursorLocalX > baseLocal.X && cursorLocalX + tagWidth > baseLocal.X + availableWidth) + break; + + var tagScreenPos = new Vector2(cursorScreenX, baseScreen.Y); + ImGui.SetCursorScreenPos(tagScreenPos); + ImGui.InvisibleButton($"##profileTagInline_{i}", tagSize); + + ProfileTagRenderer.RenderTag(tag, tagScreenPos, scale, drawList, style, _tagBackgroundColor, _tagBorderColor, defaultTextColorU32, _seResolvedSegments, GetIconWrap, _logger); + + cursorLocalX += tagWidth + style.ItemSpacing.X; + cursorScreenX += tagWidth + style.ItemSpacing.X; + rowHeight = MathF.Max(rowHeight, tagHeight); + } + + ImGui.SetCursorPos(new Vector2(baseLocal.X, baseLocal.Y + rowHeight)); + + float widthUsed = cursorLocalX - baseLocal.X; + return (widthUsed, rowHeight); + } + + private IDalamudTextureWrap? GetIconWrap(uint iconId) + { + try + { + if (_uiSharedService.TryGetIcon(iconId, out var wrap) && wrap != null) + return wrap; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve icon {IconId} for profile tags", iconId); + } + + return null; + } + private void DrawConfirmation() { if (_joinDto != null && _joinInfo != null) @@ -470,9 +621,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase finalPermissions.SetDisableVFX(_ownPermissions.DisableGroupVFX); _ = _apiController.GroupJoinFinalize(new GroupJoinDto(_joinDto.Group, _joinDto.Password, finalPermissions)); - + _recentlyJoined.Add(_joinDto.Group.GID); - + _joinDto = null; _joinInfo = null; } -- 2.49.1 From 4e4d19ad007a06e5fcb9531f8ed1d00a4db2ee5d Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 16 Dec 2025 00:04:57 +0100 Subject: [PATCH 095/140] Removed own broadcast from list, count fixed as well --- LightlessSync/Plugin.cs | 3 ++- LightlessSync/Services/ContextMenuService.cs | 2 +- LightlessSync/UI/SyncshellFinderUI.cs | 5 +++-- LightlessSync/UI/TopTabMenu.cs | 5 ++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 2cf8bdf..d8e5ee7 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -467,7 +467,8 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); diff --git a/LightlessSync/Services/ContextMenuService.cs b/LightlessSync/Services/ContextMenuService.cs index 740f52b..53bbb45 100644 --- a/LightlessSync/Services/ContextMenuService.cs +++ b/LightlessSync/Services/ContextMenuService.cs @@ -218,7 +218,7 @@ internal class ContextMenuService : IHostedService return; } - var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetBlake3Hash(); + var senderCid = (await _dalamudUtil.GetCIDAsync().ConfigureAwait(false)).ToString().GetHash256(); var receiverCid = DalamudUtilService.GetHashedCIDFromPlayerPointer(targetData.Address); _logger.LogInformation("Sending pair request: sender {SenderCid}, receiver {ReceiverCid}", senderCid, receiverCid); diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 00a008f..64f7921 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -141,7 +141,8 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } - var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts() ?? []; + var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); + var broadcasts = _broadcastScannerService.GetActiveSyncshellBroadcasts().Where(b => !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)).ToList() ?? []; var cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); @@ -158,7 +159,7 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ? shell.Group.Alias : shell.Group.GID; - broadcasterName = $"Tester of {displayName}"; + broadcasterName = $"{displayName} (Tester of TestWorld)"; } else { diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 16f3ea0..471fc11 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -799,9 +799,12 @@ public class TopTabMenu if (!_lightFinderService.IsBroadcasting) return "Syncshell Finder"; + var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); var nearbyCount = _lightFinderScannerService .GetActiveSyncshellBroadcasts() - .Where(b => !string.IsNullOrEmpty(b.GID)) + .Where(b => + !string.IsNullOrEmpty(b.GID) && + !string.Equals(b.HashedCID, myHashedCid, StringComparison.Ordinal)) .Select(b => b.GID!) .Distinct(StringComparer.Ordinal) .Count(); -- 2.49.1 From 0dd520d92682dec6d3f5634bec52f65aac23c8f4 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 16 Dec 2025 01:41:02 +0100 Subject: [PATCH 096/140] Fixed height issue of download box --- LightlessSync/UI/DownloadUi.cs | 214 ++++++++++++++++----------------- 1 file changed, 101 insertions(+), 113 deletions(-) diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index c54cf46..b960b46 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -560,9 +560,12 @@ public class DownloadUi : WindowMediatorSubscriberBase { foreach (var p in perPlayer) { - boxHeight += lineHeight + spacingY; + boxHeight += lineHeight + spacingY; - if (_configService.Current.ShowPlayerSpeedBarsTransferWindow && p.DlProg > 0) + var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow + && p.TransferredBytes > 0; + + if (showBar) { boxHeight += perPlayerBarHeight + spacingY; } @@ -630,46 +633,23 @@ public class DownloadUi : WindowMediatorSubscriberBase ); cursor.Y += lineHeight * 1.4f + spacingY; - if (_configService.Current.ShowPlayerLinesTransferWindow) + var orderedPlayers = perPlayer.OrderByDescending(p => p.TotalBytes).ToList(); + + foreach (var p in orderedPlayers) { - var orderedPlayers = perPlayer.OrderByDescending(p => p.TotalBytes).ToList(); + var hasSpeed = p.SpeedBytesPerSecond > 0; + var playerSpeedText = hasSpeed + ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" + : "-"; - foreach (var p in orderedPlayers) + var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow + && p.TransferredBytes > 0; + + var labelLine = + $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; + + if (!showBar) { - var hasSpeed = p.SpeedBytesPerSecond > 0; - var playerSpeedText = hasSpeed - ? $"{UiSharedService.ByteToString((long)p.SpeedBytesPerSecond)}/s" - : "-"; - - // Label line for the player - var labelLine = - $"{p.Name} [W:{p.DlSlot}/Q:{p.DlQueue}/P:{p.DlProg}/D:{p.DlDecomp}] {p.TransferredFiles}/{p.TotalFiles}"; - - // State flags - var isDownloading = p.DlProg > 0; - var isDecompressing = p.DlDecomp > 0 - || (!isDownloading && p.TotalBytes > 0 && p.TransferredBytes >= p.TotalBytes); - - - var showBar = _configService.Current.ShowPlayerSpeedBarsTransferWindow - && (isDownloading || isDecompressing); - - if (!showBar) - { - UiSharedService.DrawOutlinedFont( - drawList, - labelLine, - cursor, - UiSharedService.Color(255, 255, 255, _transferBoxTransparency), - UiSharedService.Color(0, 0, 0, _transferBoxTransparency), - 1 - ); - - cursor.Y += lineHeight + spacingY; - continue; - } - - // Top label line (only name + W/Q/P/D + files) UiSharedService.DrawOutlinedFont( drawList, labelLine, @@ -678,82 +658,90 @@ public class DownloadUi : WindowMediatorSubscriberBase UiSharedService.Color(0, 0, 0, _transferBoxTransparency), 1 ); + cursor.Y += lineHeight + spacingY; - - // Bar background - var barBgMin = new Vector2(boxMin.X + padding, cursor.Y); - var barBgMax = new Vector2(boxMax.X - padding, cursor.Y + perPlayerBarHeight); - - drawList.AddRectFilled( - barBgMin, - barBgMax, - UiSharedService.Color(40, 40, 40, _transferBoxTransparency), - 3f - ); - - float ratio = 0f; - if (isDownloading && p.TotalBytes > 0) - { - ratio = (float)p.TransferredBytes / p.TotalBytes; - } - else if (isDecompressing) - { - ratio = 1f; - } - - if (ratio < 0f) ratio = 0f; - if (ratio > 1f) ratio = 1f; - - var fillX = barBgMin.X + (barBgMax.X - barBgMin.X) * ratio; - var barFillMax = new Vector2(fillX, barBgMax.Y); - - drawList.AddRectFilled( - barBgMin, - barFillMax, - UiSharedService.Color(UIColors.Get("LightlessPurple")), - 3f - ); - - string barText; - - if (isDownloading) - { - var bytesInside = - $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}"; - - barText = hasSpeed - ? $"{bytesInside} @ {playerSpeedText}" - : bytesInside; - } - else if (isDecompressing) - { - barText = "Decompressing..."; - } - else - { - barText = string.Empty; - } - - if (!string.IsNullOrEmpty(barText)) - { - var barTextSize = ImGui.CalcTextSize(barText); - var barTextPos = new Vector2( - barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, - barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 - ); - - UiSharedService.DrawOutlinedFont( - drawList, - barText, - barTextPos, - UiSharedService.Color(255, 255, 255, _transferBoxTransparency), - UiSharedService.Color(0, 0, 0, _transferBoxTransparency), - 1 - ); - } - - cursor.Y += perPlayerBarHeight + spacingY; + continue; } + + UiSharedService.DrawOutlinedFont( + drawList, + labelLine, + cursor, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + cursor.Y += lineHeight + spacingY; + + // Bar background + var barBgMin = new Vector2(boxMin.X + padding, cursor.Y); + var barBgMax = new Vector2(boxMax.X - padding, cursor.Y + perPlayerBarHeight); + + drawList.AddRectFilled( + barBgMin, + barBgMax, + UiSharedService.Color(40, 40, 40, _transferBoxTransparency), + 3f + ); + + // Fill based on Progress of download + float ratio = 0f; + if (p.TotalBytes > 0) + ratio = (float)p.TransferredBytes / p.TotalBytes; + + if (ratio < 0f) ratio = 0f; + if (ratio > 1f) ratio = 1f; + + var fillX = barBgMin.X + (barBgMax.X - barBgMin.X) * ratio; + var barFillMax = new Vector2(fillX, barBgMax.Y); + + drawList.AddRectFilled( + barBgMin, + barFillMax, + UiSharedService.Color(UIColors.Get("LightlessPurple")), + 3f + ); + + // Text inside bar: downloading vs decompressing + string barText; + + var isDecompressing = p.DlDecomp > 0 && p.TransferredBytes >= p.TotalBytes && p.TotalBytes > 0; + + if (isDecompressing) + { + // Keep bar full, static text showing decompressing + barText = "Decompressing..."; + } + else + { + var bytesInside = + $"{UiSharedService.ByteToString(p.TransferredBytes, addSuffix: false)}/{UiSharedService.ByteToString(p.TotalBytes)}"; + + barText = hasSpeed + ? $"{bytesInside} @ {playerSpeedText}" + : bytesInside; + } + + if (!string.IsNullOrEmpty(barText)) + { + var barTextSize = ImGui.CalcTextSize(barText); + + var barTextPos = new Vector2( + barBgMin.X + ((barBgMax.X - barBgMin.X) - barTextSize.X) / 2f - 1, + barBgMin.Y + ((perPlayerBarHeight - barTextSize.Y) / 2f) - 1 + ); + + UiSharedService.DrawOutlinedFont( + drawList, + barText, + barTextPos, + UiSharedService.Color(255, 255, 255, _transferBoxTransparency), + UiSharedService.Color(0, 0, 0, _transferBoxTransparency), + 1 + ); + } + + cursor.Y += perPlayerBarHeight + spacingY; } } -- 2.49.1 From 5dabd23d938334bd8a1aa5614371fb5cc7fd9f51 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 16 Dec 2025 01:57:07 +0100 Subject: [PATCH 097/140] Fixed null exception on CID --- LightlessSync/UI/SyncshellFinderUI.cs | 14 +++++++++++--- LightlessSync/UI/TopTabMenu.cs | 19 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 64f7921..2f215a1 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -4,12 +4,12 @@ using Dalamud.Interface.Colors; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Services; using LightlessSync.API.Data; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Group; -using LightlessSync.API.Dto.User; using LightlessSync.Services; using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; @@ -78,7 +78,6 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync().ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); Mediator.Subscribe(this, async _ => await RefreshSyncshellsAsync(_.gid).ConfigureAwait(false)); - } public override async void OnOpen() @@ -141,7 +140,16 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase return; } - var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); + 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 cardData = new List<(GroupJoinDto Shell, string BroadcasterName)>(); diff --git a/LightlessSync/UI/TopTabMenu.cs b/LightlessSync/UI/TopTabMenu.cs index 471fc11..cc69a5d 100644 --- a/LightlessSync/UI/TopTabMenu.cs +++ b/LightlessSync/UI/TopTabMenu.cs @@ -1,19 +1,20 @@ -using System; using Dalamud.Bindings.ImGui; using Dalamud.Interface; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Services; using Dalamud.Utility; using LightlessSync.API.Data.Enum; using LightlessSync.API.Data.Extensions; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; -using LightlessSync.Services.Mediator; using LightlessSync.Services.LightFinder; -using LightlessSync.Utils; +using LightlessSync.Services.Mediator; using LightlessSync.UI.Models; using LightlessSync.UI.Style; +using LightlessSync.Utils; using LightlessSync.WebAPI; +using System; using System.Numerics; namespace LightlessSync.UI; @@ -799,7 +800,17 @@ public class TopTabMenu if (!_lightFinderService.IsBroadcasting) return "Syncshell Finder"; - var myHashedCid = _dalamudUtilService.GetCID().ToString().GetHash256(); + 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 => -- 2.49.1 From dec6c4900b9a7ba7826bf3b2dd367aba2c2e081d Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 16 Dec 2025 02:01:30 +0100 Subject: [PATCH 098/140] bumped version in project --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 8930cb6..b46cccc 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -3,7 +3,7 @@ - 1.12.4 + 2.0.0 https://github.com/Light-Public-Syncshells/LightlessClient -- 2.49.1 From a41f419076f093f9a1841f5843d92e03a836b410 Mon Sep 17 00:00:00 2001 From: cake Date: Tue, 16 Dec 2025 07:02:35 +0100 Subject: [PATCH 099/140] Reduced message size and reason length of report --- LightlessSync/Services/Chat/ZoneChatService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index edb3a86..8e86b49 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -12,11 +12,11 @@ namespace LightlessSync.Services.Chat; public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService { private const int MaxMessageHistory = 150; - internal const int MaxOutgoingLength = 400; + internal const int MaxOutgoingLength = 200; private const int MaxUnreadCount = 999; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneChannelKey = "zone"; - private const int MaxReportReasonLength = 500; + private const int MaxReportReasonLength = 100; private const int MaxReportContextLength = 1000; private readonly ApiController _apiController; -- 2.49.1 From 755bae1294239e5ee7b1340d1d2acd71011e1e12 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 16 Dec 2025 11:35:52 +0100 Subject: [PATCH 100/140] matching the notifications to the new styling --- LightlessSync/UI/LightlessNotificationUI.cs | 40 +++++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/LightlessSync/UI/LightlessNotificationUI.cs b/LightlessSync/UI/LightlessNotificationUI.cs index 9c4f8f5..1d0a477 100644 --- a/LightlessSync/UI/LightlessNotificationUI.cs +++ b/LightlessSync/UI/LightlessNotificationUI.cs @@ -6,6 +6,7 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.UI.Models; +using LightlessSync.UI.Style; using Microsoft.Extensions.Logging; using System.Numerics; @@ -30,6 +31,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase private readonly LightlessConfigService _configService; private readonly Dictionary _notificationYOffsets = []; private readonly Dictionary _notificationTargetYOffsets = []; + private readonly Dictionary _notificationBackgrounds = []; public LightlessNotificationUi(ILogger logger, LightlessMediator mediator, PerformanceCollectorService performanceCollector, LightlessConfigService configService) : base(logger, mediator, "Lightless Notifications##LightlessNotifications", performanceCollector) @@ -225,6 +227,7 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase _notifications.RemoveAt(i); _notificationYOffsets.Remove(notification.Id); _notificationTargetYOffsets.Remove(notification.Id); + _notificationBackgrounds.Remove(notification.Id); } } } @@ -333,14 +336,15 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); - var bgColor = CalculateBackgroundColor(alpha, ImGui.IsWindowHovered()); var accentColor = GetNotificationAccentColor(notification.Type); - accentColor.W *= alpha; + var bgColor = CalculateBackgroundColor(notification, alpha, ImGui.IsWindowHovered(), accentColor); + var accentColorWithAlpha = accentColor; + accentColorWithAlpha.W *= alpha; DrawShadow(drawList, windowPos, windowSize, alpha); HandleClickToDismiss(notification); DrawBackground(drawList, windowPos, windowSize, bgColor); - DrawAccentBar(drawList, windowPos, windowSize, accentColor); + DrawAccentBar(drawList, windowPos, windowSize, accentColorWithAlpha); DrawDurationProgressBar(notification, alpha, windowPos, windowSize, drawList); // Draw download progress bar above duration bar for download notifications @@ -352,16 +356,38 @@ public class LightlessNotificationUi : WindowMediatorSubscriberBase DrawNotificationText(notification, alpha); } - private Vector4 CalculateBackgroundColor(float alpha, bool isHovered) + private Vector4 CalculateBackgroundColor(LightlessNotification notification, float alpha, bool isHovered, Vector4 accentColor) { var baseOpacity = _configService.Current.NotificationOpacity; var finalOpacity = baseOpacity * alpha; - var bgColor = new Vector4(30f/255f, 30f/255f, 30f/255f, finalOpacity); + float boost = Luminance.ComputeHighlight(null, accentColor); + + var baseBg = new Vector4( + 30f/255f + boost, + 30f/255f + boost, + 30f/255f + boost, + finalOpacity + ); + + if (!_notificationBackgrounds.ContainsKey(notification.Id)) + { + _notificationBackgrounds[notification.Id] = baseBg; + } + + var currentBg = _notificationBackgrounds[notification.Id]; + var bgColor = Luminance.BackgroundContrast(null, accentColor, baseBg, ref currentBg); + _notificationBackgrounds[notification.Id] = currentBg; + + bgColor.W = finalOpacity; if (isHovered) { - bgColor *= 1.1f; - bgColor.W = Math.Min(bgColor.W, 0.98f); + bgColor = new Vector4( + bgColor.X * 1.1f, + bgColor.Y * 1.1f, + bgColor.Z * 1.1f, + Math.Min(bgColor.W, 0.98f) + ); } return bgColor; -- 2.49.1 From 8b9e35283d233852f34a0fd42f108d0a6ff17641 Mon Sep 17 00:00:00 2001 From: choco Date: Tue, 16 Dec 2025 11:49:56 +0100 Subject: [PATCH 101/140] reworked the animated star banner into a reusable component for reusability --- LightlessSync/UI/Style/AnimatedHeader.cs | 463 +++++++++++++++++++++++ LightlessSync/UI/UpdateNotesUi.cs | 394 +------------------ 2 files changed, 477 insertions(+), 380 deletions(-) create mode 100644 LightlessSync/UI/Style/AnimatedHeader.cs diff --git a/LightlessSync/UI/Style/AnimatedHeader.cs b/LightlessSync/UI/Style/AnimatedHeader.cs new file mode 100644 index 0000000..0037b53 --- /dev/null +++ b/LightlessSync/UI/Style/AnimatedHeader.cs @@ -0,0 +1,463 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using System.Numerics; + +namespace LightlessSync.UI.Style; + +/// +/// A reusable animated header component with a gradient background, some funny stars, and shooting star effects to match the lightless void theme a bit. +/// +public class AnimatedHeader +{ + private struct Particle + { + public Vector2 Position; + public Vector2 Velocity; + public float Life; + public float MaxLife; + public float Size; + public ParticleType Type; + public List? Trail; + public float Twinkle; + public float Depth; + public float Hue; + } + + private enum ParticleType + { + TwinklingStar, + ShootingStar + } + + private readonly List _particles = []; + private float _particleSpawnTimer; + private readonly Random _random = new(); + + private const float _particleSpawnInterval = 0.2f; + private const int _maxParticles = 50; + private const int _maxTrailLength = 50; + private const float _edgeFadeDistance = 30f; + private const float _extendedParticleHeight = 40f; + + public float Height { get; set; } = 150f; + public Vector4 TopColor { get; set; } = new(0.08f, 0.05f, 0.15f, 1.0f); + public Vector4 BottomColor { get; set; } = new(0.12f, 0.08f, 0.20f, 1.0f); + public bool EnableParticles { get; set; } = true; + public bool EnableBottomGradient { get; set; } = true; + + /// + /// Draws the animated header with some customizable content + /// + /// Width of the header + /// Action to draw custom content inside the header + public void Draw(float width, Action drawContent) + { + var windowPos = ImGui.GetWindowPos(); + var windowPadding = ImGui.GetStyle().WindowPadding; + + var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y); + var headerEnd = headerStart + new Vector2(width, Height); + var extendedParticleSize = new Vector2(width, Height + _extendedParticleHeight); + + DrawGradientBackground(headerStart, headerEnd); + + if (EnableParticles) + { + DrawParticleEffects(headerStart, extendedParticleSize); + } + + drawContent(headerStart, headerEnd); + + if (EnableBottomGradient) + { + DrawBottomGradient(headerStart, headerEnd, width); + } + } + + /// + /// Draws a simple animated header with title and subtitle. + /// + public void DrawSimple(float width, string title, string subtitle, IFontHandle? titleFont = null, Vector4? titleColor = null, Vector4? subtitleColor = null) + { + Draw(width, (headerStart, headerEnd) => + { + var textX = 20f; + var textY = 30f; + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY)); + + if (titleFont != null) + { + using (titleFont.Push()) + { + ImGui.TextColored(titleColor ?? new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title); + } + } + else + { + ImGui.TextColored(titleColor ?? new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title); + } + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f)); + ImGui.TextColored(subtitleColor ?? UIColors.Get("LightlessBlue"), subtitle); + }); + } + + /// + /// Draws a header with title, subtitle, and action buttons in the top-right corner. + /// + public void DrawWithButtons(float width, string title, string subtitle, List buttons, IFontHandle? titleFont = null) + { + Draw(width, (headerStart, headerEnd) => + { + // Draw title and subtitle + var textX = 20f; + var textY = 30f; + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY)); + + if (titleFont != null) + { + using (titleFont.Push()) + { + ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title); + } + } + else + { + ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), title); + } + + ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f)); + ImGui.TextColored(UIColors.Get("LightlessBlue"), subtitle); + + // Draw buttons + if (buttons.Count > 0) + { + DrawHeaderButtons(headerStart, width, buttons); + } + }); + } + + private void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) + { + var drawList = ImGui.GetWindowDrawList(); + + drawList.AddRectFilledMultiColor( + headerStart, + headerEnd, + ImGui.GetColorU32(TopColor), + ImGui.GetColorU32(TopColor), + ImGui.GetColorU32(BottomColor), + ImGui.GetColorU32(BottomColor) + ); + + // Draw static background stars + var random = new Random(42); + for (int i = 0; i < 50; i++) + { + var starPos = headerStart + new Vector2( + (float)random.NextDouble() * (headerEnd.X - headerStart.X), + (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) + ); + var brightness = 0.3f + (float)random.NextDouble() * 0.4f; + drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); + } + } + + private void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) + { + var drawList = ImGui.GetWindowDrawList(); + var gradientHeight = 60f; + + for (int i = 0; i < gradientHeight; i++) + { + var progress = i / gradientHeight; + var smoothProgress = progress * progress; + var r = BottomColor.X + (0.0f - BottomColor.X) * smoothProgress; + var g = BottomColor.Y + (0.0f - BottomColor.Y) * smoothProgress; + var b = BottomColor.Z + (0.0f - BottomColor.Z) * smoothProgress; + var alpha = 1f - smoothProgress; + var gradientColor = new Vector4(r, g, b, alpha); + drawList.AddLine( + new Vector2(headerStart.X, headerEnd.Y + i), + new Vector2(headerStart.X + width, headerEnd.Y + i), + ImGui.GetColorU32(gradientColor), + 1f + ); + } + } + + private void DrawHeaderButtons(Vector2 headerStart, float headerWidth, List buttons) + { + var spacing = 8f * ImGuiHelpers.GlobalScale; + var rightPadding = 15f * ImGuiHelpers.GlobalScale; + var topPadding = 15f * ImGuiHelpers.GlobalScale; + var buttonY = headerStart.Y + topPadding; + + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + // Calculate button size (assuming all buttons are the same size) + var buttonSize = ImGui.CalcTextSize(FontAwesomeIcon.Globe.ToIconString()); + buttonSize += ImGui.GetStyle().FramePadding * 2; + + float currentX = headerStart.X + headerWidth - rightPadding - buttonSize.X; + + using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0))) + using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f })) + using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f })) + { + for (int i = buttons.Count - 1; i >= 0; i--) + { + var button = buttons[i]; + ImGui.SetCursorScreenPos(new Vector2(currentX, buttonY)); + + if (ImGui.Button(button.Icon.ToIconString())) + { + button.OnClick?.Invoke(); + } + + if (ImGui.IsItemHovered() && !string.IsNullOrEmpty(button.Tooltip)) + { + ImGui.SetTooltip(button.Tooltip); + } + + currentX -= buttonSize.X + spacing; + } + } + } + } + + private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize) + { + var deltaTime = ImGui.GetIO().DeltaTime; + _particleSpawnTimer += deltaTime; + + if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles) + { + SpawnParticle(bannerSize); + _particleSpawnTimer = 0f; + } + + if (_random.NextDouble() < 0.003) + { + SpawnShootingStar(bannerSize); + } + + var drawList = ImGui.GetWindowDrawList(); + + for (int i = _particles.Count - 1; i >= 0; i--) + { + var particle = _particles[i]; + + var screenPos = bannerStart + particle.Position; + + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null) + { + particle.Trail.Insert(0, particle.Position); + if (particle.Trail.Count > _maxTrailLength) + particle.Trail.RemoveAt(particle.Trail.Count - 1); + } + + if (particle.Type == ParticleType.TwinklingStar) + { + particle.Twinkle += 0.005f * particle.Depth; + } + + particle.Position += particle.Velocity * deltaTime; + particle.Life -= deltaTime; + + var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 || + particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50; + + if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds)) + { + _particles.RemoveAt(i); + continue; + } + + if (particle.Type == ParticleType.TwinklingStar) + { + if (particle.Position.X < 0 || particle.Position.X > bannerSize.X) + particle.Velocity = particle.Velocity with { X = -particle.Velocity.X }; + if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y) + particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y }; + } + + var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f); + var fadeOut = Math.Min(1f, particle.Life / 20f); + var lifeFade = Math.Min(fadeIn, fadeOut); + + var edgeFadeX = Math.Min( + Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance), + Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance) + ); + var edgeFadeY = Math.Min( + Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance), + Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance) + ); + var edgeFade = Math.Min(edgeFadeX, edgeFadeY); + + var baseAlpha = lifeFade * edgeFade; + var finalAlpha = particle.Type == ParticleType.TwinklingStar + ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) + : baseAlpha; + + if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) + { + var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); + + for (int t = 1; t < particle.Trail.Count; t++) + { + var trailProgress = (float)t / particle.Trail.Count; + var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f); + var trailWidth = (1f - trailProgress) * 3f + 1f; + + var glowAlpha = trailAlpha * 0.4f; + drawList.AddLine( + bannerStart + particle.Trail[t - 1], + bannerStart + particle.Trail[t], + ImGui.GetColorU32(cyanColor with { W = glowAlpha }), + trailWidth + 4f + ); + + drawList.AddLine( + bannerStart + particle.Trail[t - 1], + bannerStart + particle.Trail[t], + ImGui.GetColorU32(cyanColor with { W = trailAlpha }), + trailWidth + ); + } + } + else if (particle.Type == ParticleType.TwinklingStar) + { + DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth); + } + + _particles[i] = particle; + } + } + + private static void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha, float depth) + { + var color = HslToRgb(hue, 1.0f, 0.85f); + color.W = alpha; + + drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color)); + + var glowColor = color with { W = alpha * 0.3f }; + drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor)); + } + + private static Vector4 HslToRgb(float h, float s, float l) + { + h = h / 360f; + float c = (1 - MathF.Abs(2 * l - 1)) * s; + float x = c * (1 - MathF.Abs((h * 6) % 2 - 1)); + float m = l - c / 2; + + float r, g, b; + if (h < 1f / 6f) + { + r = c; g = x; b = 0; + } + else if (h < 2f / 6f) + { + r = x; g = c; b = 0; + } + else if (h < 3f / 6f) + { + r = 0; g = c; b = x; + } + else if (h < 4f / 6f) + { + r = 0; g = x; b = c; + } + else if (h < 5f / 6f) + { + r = x; g = 0; b = c; + } + else + { + r = c; g = 0; b = x; + } + + return new Vector4(r + m, g + m, b + m, 1.0f); + } + + private void SpawnParticle(Vector2 bannerSize) + { + var position = new Vector2( + (float)_random.NextDouble() * bannerSize.X, + (float)_random.NextDouble() * bannerSize.Y + ); + + var depthLayers = new[] { 0.5f, 1.0f, 1.5f }; + var depth = depthLayers[_random.Next(depthLayers.Length)]; + + var velocity = new Vector2( + ((float)_random.NextDouble() - 0.5f) * 0.05f * depth, + ((float)_random.NextDouble() - 0.5f) * 0.05f * depth + ); + + var isBlue = _random.NextDouble() < 0.5; + var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f; + var size = (0.5f + (float)_random.NextDouble() * 2f) * depth; + var maxLife = 120f + (float)_random.NextDouble() * 60f; + + _particles.Add(new Particle + { + Position = position, + Velocity = velocity, + Life = maxLife, + MaxLife = maxLife, + Size = size, + Type = ParticleType.TwinklingStar, + Trail = null, + Twinkle = (float)_random.NextDouble() * MathF.PI * 2, + Depth = depth, + Hue = hue + }); + } + + private void SpawnShootingStar(Vector2 bannerSize) + { + var maxLife = 80f + (float)_random.NextDouble() * 40f; + var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f); + var startY = -10f; + + _particles.Add(new Particle + { + Position = new Vector2(startX, startY), + Velocity = new Vector2( + -50f - (float)_random.NextDouble() * 40f, + 30f + (float)_random.NextDouble() * 40f + ), + Life = maxLife, + MaxLife = maxLife, + Size = 2.5f, + Type = ParticleType.ShootingStar, + Trail = new List(), + Twinkle = 0, + Depth = 1.0f, + Hue = 270f + }); + } + + /// + /// Clears all active particles. Useful when closing or hiding a window with an animated header. + /// + public void ClearParticles() + { + _particles.Clear(); + _particleSpawnTimer = 0f; + } +} + +/// +/// Represents a button in the animated header. +/// +public record HeaderButton(FontAwesomeIcon Icon, string Tooltip, Action? OnClick = null); diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index c5331a0..340253f 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -13,6 +13,7 @@ using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using Dalamud.Interface; using LightlessSync.UI.Models; +using LightlessSync.UI.Style; using LightlessSync.Utils; namespace LightlessSync.UI; @@ -27,37 +28,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase private CreditsFile _credits = new(); private bool _scrollToTop; private bool _hasInitializedCollapsingHeaders; - - private struct Particle - { - public Vector2 Position; - public Vector2 Velocity; - public float Life; - public float MaxLife; - public float Size; - public ParticleType Type; - public List? Trail; - public float Twinkle; - public float Depth; - public float Hue; - } - - private enum ParticleType - { - TwinklingStar, - ShootingStar - } - - private readonly List _particles = []; - private float _particleSpawnTimer; - private readonly Random _random = new(); - - private const float _headerHeight = 150f; - private const float _particleSpawnInterval = 0.2f; - private const int _maxParticles = 50; - private const int _maxTrailLength = 50; - private const float _edgeFadeDistance = 30f; - private const float _extendedParticleHeight = 40f; + private readonly AnimatedHeader _animatedHeader = new(); public UpdateNotesUi(ILogger logger, LightlessMediator mediator, @@ -94,6 +65,11 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase _hasInitializedCollapsingHeaders = false; } + public override void OnClose() + { + _animatedHeader.ClearParticles(); + } + private void CenterWindow() { var viewport = ImGui.GetMainViewport(); @@ -116,21 +92,18 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase private void DrawHeader() { - var windowPos = ImGui.GetWindowPos(); var windowPadding = ImGui.GetStyle().WindowPadding; var headerWidth = (800f * ImGuiHelpers.GlobalScale) - (windowPadding.X * 2); - var headerStart = windowPos + new Vector2(windowPadding.X, windowPadding.Y); - var headerEnd = headerStart + new Vector2(headerWidth, _headerHeight); + var buttons = new List + { + new(FontAwesomeIcon.Comments, "Join our Discord", () => Util.OpenLink("https://discord.gg/dsbjcXMnhA")), + new(FontAwesomeIcon.Code, "View on Git", () => Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync")) + }; - var extendedParticleSize = new Vector2(headerWidth, _headerHeight + _extendedParticleHeight); + _animatedHeader.DrawWithButtons(headerWidth, "Lightless Sync", "Update Notes", buttons, _uiShared.UidFont); - DrawGradientBackground(headerStart, headerEnd); - DrawHeaderText(headerStart); - DrawHeaderButtons(headerStart, headerWidth); - DrawBottomGradient(headerStart, headerEnd, headerWidth); - - ImGui.SetCursorPosY(windowPadding.Y + _headerHeight + 5); + ImGui.SetCursorPosY(windowPadding.Y + _animatedHeader.Height + 5); ImGui.SetCursorPosX(20); using (ImRaii.PushFont(UiBuilder.IconFont)) { @@ -155,347 +128,8 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase } ImGuiHelpers.ScaledDummy(3); - - DrawParticleEffects(headerStart, extendedParticleSize); } - private static void DrawGradientBackground(Vector2 headerStart, Vector2 headerEnd) - { - var drawList = ImGui.GetWindowDrawList(); - - var darkPurple = new Vector4(0.08f, 0.05f, 0.15f, 1.0f); - var deepPurple = new Vector4(0.12f, 0.08f, 0.20f, 1.0f); - - drawList.AddRectFilledMultiColor( - headerStart, - headerEnd, - ImGui.GetColorU32(darkPurple), - ImGui.GetColorU32(darkPurple), - ImGui.GetColorU32(deepPurple), - ImGui.GetColorU32(deepPurple) - ); - - var random = new Random(42); - for (int i = 0; i < 50; i++) - { - var starPos = headerStart + new Vector2( - (float)random.NextDouble() * (headerEnd.X - headerStart.X), - (float)random.NextDouble() * (headerEnd.Y - headerStart.Y) - ); - var brightness = 0.3f + (float)random.NextDouble() * 0.4f; - drawList.AddCircleFilled(starPos, 1f, ImGui.GetColorU32(new Vector4(1f, 1f, 1f, brightness))); - } - } - - private static void DrawBottomGradient(Vector2 headerStart, Vector2 headerEnd, float width) - { - var drawList = ImGui.GetWindowDrawList(); - var gradientHeight = 60f; - - for (int i = 0; i < gradientHeight; i++) - { - var progress = i / gradientHeight; - var smoothProgress = progress * progress; - var r = 0.12f + (0.0f - 0.12f) * smoothProgress; - var g = 0.08f + (0.0f - 0.08f) * smoothProgress; - var b = 0.20f + (0.0f - 0.20f) * smoothProgress; - var alpha = 1f - smoothProgress; - var gradientColor = new Vector4(r, g, b, alpha); - drawList.AddLine( - new Vector2(headerStart.X, headerEnd.Y + i), - new Vector2(headerStart.X + width, headerEnd.Y + i), - ImGui.GetColorU32(gradientColor), - 1f - ); - } - } - - private void DrawHeaderText(Vector2 headerStart) - { - var textX = 20f; - var textY = 30f; - - ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY)); - - using (_uiShared.UidFont.Push()) - { - ImGui.TextColored(new Vector4(0.95f, 0.95f, 0.95f, 1.0f), "Lightless Sync"); - } - - ImGui.SetCursorScreenPos(headerStart + new Vector2(textX, textY + 45f)); - ImGui.TextColored(UIColors.Get("LightlessBlue"), "Update Notes"); - } - - private void DrawHeaderButtons(Vector2 headerStart, float headerWidth) - { - var buttonSize = _uiShared.GetIconButtonSize(FontAwesomeIcon.Globe); - var spacing = 8f * ImGuiHelpers.GlobalScale; - var rightPadding = 15f * ImGuiHelpers.GlobalScale; - var topPadding = 15f * ImGuiHelpers.GlobalScale; - var buttonY = headerStart.Y + topPadding; - var gitButtonX = headerStart.X + headerWidth - rightPadding - buttonSize.X; - var discordButtonX = gitButtonX - buttonSize.X - spacing; - - ImGui.SetCursorScreenPos(new Vector2(discordButtonX, buttonY)); - - using (ImRaii.PushColor(ImGuiCol.Button, new Vector4(0, 0, 0, 0))) - using (ImRaii.PushColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple") with { W = 0.3f })) - using (ImRaii.PushColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive") with { W = 0.5f })) - { - if (_uiShared.IconButton(FontAwesomeIcon.Comments)) - { - Util.OpenLink("https://discord.gg/dsbjcXMnhA"); - } - - if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip("Join our Discord"); - } - - ImGui.SetCursorScreenPos(new Vector2(gitButtonX, buttonY)); - if (_uiShared.IconButton(FontAwesomeIcon.Code)) - { - Util.OpenLink("https://git.lightless-sync.org/Lightless-Sync"); - } - - if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip("View on Git"); - } - } - } - - private void DrawParticleEffects(Vector2 bannerStart, Vector2 bannerSize) - { - var deltaTime = ImGui.GetIO().DeltaTime; - _particleSpawnTimer += deltaTime; - - if (_particleSpawnTimer > _particleSpawnInterval && _particles.Count < _maxParticles) - { - SpawnParticle(bannerSize); - _particleSpawnTimer = 0f; - } - - if (_random.NextDouble() < 0.003) - { - SpawnShootingStar(bannerSize); - } - - var drawList = ImGui.GetWindowDrawList(); - - for (int i = _particles.Count - 1; i >= 0; i--) - { - var particle = _particles[i]; - - var screenPos = bannerStart + particle.Position; - - if (particle.Type == ParticleType.ShootingStar && particle.Trail != null) - { - particle.Trail.Insert(0, particle.Position); - if (particle.Trail.Count > _maxTrailLength) - particle.Trail.RemoveAt(particle.Trail.Count - 1); - } - - if (particle.Type == ParticleType.TwinklingStar) - { - particle.Twinkle += 0.005f * particle.Depth; - } - - particle.Position += particle.Velocity * deltaTime; - particle.Life -= deltaTime; - - var isOutOfBounds = particle.Position.X < -50 || particle.Position.X > bannerSize.X + 50 || - particle.Position.Y < -50 || particle.Position.Y > bannerSize.Y + 50; - - if (particle.Life <= 0 || (particle.Type != ParticleType.TwinklingStar && isOutOfBounds)) - { - _particles.RemoveAt(i); - continue; - } - - if (particle.Type == ParticleType.TwinklingStar) - { - if (particle.Position.X < 0 || particle.Position.X > bannerSize.X) - particle.Velocity = particle.Velocity with { X = -particle.Velocity.X }; - if (particle.Position.Y < 0 || particle.Position.Y > bannerSize.Y) - particle.Velocity = particle.Velocity with { Y = -particle.Velocity.Y }; - } - - var fadeIn = Math.Min(1f, (particle.MaxLife - particle.Life) / 20f); - var fadeOut = Math.Min(1f, particle.Life / 20f); - var lifeFade = Math.Min(fadeIn, fadeOut); - - var edgeFadeX = Math.Min( - Math.Min(1f, (particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance), - Math.Min(1f, (bannerSize.X - particle.Position.X + _edgeFadeDistance) / _edgeFadeDistance) - ); - var edgeFadeY = Math.Min( - Math.Min(1f, (particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance), - Math.Min(1f, (bannerSize.Y - particle.Position.Y + _edgeFadeDistance) / _edgeFadeDistance) - ); - var edgeFade = Math.Min(edgeFadeX, edgeFadeY); - - var baseAlpha = lifeFade * edgeFade; - var finalAlpha = particle.Type == ParticleType.TwinklingStar - ? baseAlpha * (0.6f + 0.4f * MathF.Sin(particle.Twinkle)) - : baseAlpha; - - if (particle.Type == ParticleType.ShootingStar && particle.Trail != null && particle.Trail.Count > 1) - { - var cyanColor = new Vector4(0.4f, 0.8f, 1.0f, 1.0f); - - for (int t = 1; t < particle.Trail.Count; t++) - { - var trailProgress = (float)t / particle.Trail.Count; - var trailAlpha = Math.Min(1f, (1f - trailProgress) * finalAlpha * 1.8f); - var trailWidth = (1f - trailProgress) * 3f + 1f; - - var glowAlpha = trailAlpha * 0.4f; - drawList.AddLine( - bannerStart + particle.Trail[t - 1], - bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = glowAlpha }), - trailWidth + 4f - ); - - drawList.AddLine( - bannerStart + particle.Trail[t - 1], - bannerStart + particle.Trail[t], - ImGui.GetColorU32(cyanColor with { W = trailAlpha }), - trailWidth - ); - } - } - else if (particle.Type == ParticleType.TwinklingStar) - { - DrawTwinklingStar(drawList, screenPos, particle.Size, particle.Hue, finalAlpha, particle.Depth); - } - - _particles[i] = particle; - } - } - - private void DrawTwinklingStar(ImDrawListPtr drawList, Vector2 position, float size, float hue, float alpha, - float depth) - { - var color = HslToRgb(hue, 1.0f, 0.85f); - color.W = alpha; - - drawList.AddCircleFilled(position, size, ImGui.GetColorU32(color)); - - var glowColor = color with { W = alpha * 0.3f }; - drawList.AddCircleFilled(position, size * (1.2f + depth * 0.3f), ImGui.GetColorU32(glowColor)); - } - - private static Vector4 HslToRgb(float h, float s, float l) - { - h = h / 360f; - float c = (1 - MathF.Abs(2 * l - 1)) * s; - float x = c * (1 - MathF.Abs((h * 6) % 2 - 1)); - float m = l - c / 2; - - float r, g, b; - if (h < 1f / 6f) - { - r = c; - g = x; - b = 0; - } - else if (h < 2f / 6f) - { - r = x; - g = c; - b = 0; - } - else if (h < 3f / 6f) - { - r = 0; - g = c; - b = x; - } - else if (h < 4f / 6f) - { - r = 0; - g = x; - b = c; - } - else if (h < 5f / 6f) - { - r = x; - g = 0; - b = c; - } - else - { - r = c; - g = 0; - b = x; - } - - return new Vector4(r + m, g + m, b + m, 1.0f); - } - - - private void SpawnParticle(Vector2 bannerSize) - { - var position = new Vector2( - (float)_random.NextDouble() * bannerSize.X, - (float)_random.NextDouble() * bannerSize.Y - ); - - var depthLayers = new[] { 0.5f, 1.0f, 1.5f }; - var depth = depthLayers[_random.Next(depthLayers.Length)]; - - var velocity = new Vector2( - ((float)_random.NextDouble() - 0.5f) * 0.05f * depth, - ((float)_random.NextDouble() - 0.5f) * 0.05f * depth - ); - - var isBlue = _random.NextDouble() < 0.5; - var hue = isBlue ? 220f + (float)_random.NextDouble() * 30f : 270f + (float)_random.NextDouble() * 40f; - var size = (0.5f + (float)_random.NextDouble() * 2f) * depth; - var maxLife = 120f + (float)_random.NextDouble() * 60f; - - _particles.Add(new Particle - { - Position = position, - Velocity = velocity, - Life = maxLife, - MaxLife = maxLife, - Size = size, - Type = ParticleType.TwinklingStar, - Trail = null, - Twinkle = (float)_random.NextDouble() * MathF.PI * 2, - Depth = depth, - Hue = hue - }); - } - - private void SpawnShootingStar(Vector2 bannerSize) - { - var maxLife = 80f + (float)_random.NextDouble() * 40f; - var startX = bannerSize.X * (0.3f + (float)_random.NextDouble() * 0.6f); - var startY = -10f; - - _particles.Add(new Particle - { - Position = new Vector2(startX, startY), - Velocity = new Vector2( - -50f - (float)_random.NextDouble() * 40f, - 30f + (float)_random.NextDouble() * 40f - ), - Life = maxLife, - MaxLife = maxLife, - Size = 2.5f, - Type = ParticleType.ShootingStar, - Trail = new List(), - Twinkle = 0, - Depth = 1.0f, - Hue = 270f - }); - } - - private void DrawTabs() { using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f)) -- 2.49.1 From 6522b586d5a1929f3dd89dbe556fff9ebe9b397a Mon Sep 17 00:00:00 2001 From: defnotken Date: Tue, 16 Dec 2025 11:18:54 -0600 Subject: [PATCH 102/140] Update changelog. --- LightlessSync/Changelog/changelog.yaml | 77 ++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/LightlessSync/Changelog/changelog.yaml b/LightlessSync/Changelog/changelog.yaml index 43b0c79..18a0a8c 100644 --- a/LightlessSync/Changelog/changelog.yaml +++ b/LightlessSync/Changelog/changelog.yaml @@ -1,11 +1,80 @@ -tagline: "Lightless Sync v1.12.4" -subline: "Bugfixes and various improvements across Lightless" +tagline: "Lightless Sync v2.0.0" +subline: "LIGHTLESS IS EVOLVING!!" changelog: + - 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: "" + items: + - "Chat has been added to the top of the main UI. It will work in certain Zones or in Syncshells!" + - "You will only be able to use the chat feature after enabling it and accepting the rules. If you're not interested, don't use it!" + - "Breaking the rules may result in a mute or ban from chat. Serious offenses may result in a ban from the Lightless service altogether." + - "You can right click the offender in the chat and report them within the chat, reports will be reviewed asap." + - "Syncshells can enforce their own chat rules and moderate their own chat. This however does not apply to serious offenses." + - "Your name in chat will not be shown unless you are paired with the person OR you are in the same syncshell. Otherwise, you will be anonymous." + - "Refer to #release-notes in the Discord for more information. Feel free to ask questions in the Discord as well." + - number: "Changes to LightFinder" + icon: "" + items: + - "We have recieve quite a bit of reports of users crashing due to how Nameplates are handled across various plugins. As a result, we have moved the LightFinder icon and text to Imgui." + - "This should resolve the crashing issues, however, it may not look as nice as before. We are looking into ways to improve the Imgui experience in the future." + - "We will always prioritize stability and safety over visuals." + - "Refer to #release-notes in the Discord for an example of the error." + - number: "User Profiles, ShellFinder, Syncshells, Syncshell Profiles" + icon: "" + items: + - "Both User Profiles and Syncshell Profiles have been revamped for 2.0.0." + - "We have added profile tags to both Users and Syncshells that will show when a profile is being viewed" + - "Syncshell Admin Panel has been reworked to make it a friendlier experience" + - "Syncshell Moderators can now also broadcast on ShellFinder" + - "ShellFinder has been revamped to be more visually friends and also show more information (Tags) about the Syncshell" + - "Syncshells has an auto-prune feature now that will remove inactive members after a set amount of time, options available are 1, 3, 7, and 14 days that runs in 1 hour intervals" + - "IF YOUR SYNCSHELL IS NSFW, PLEASE MARK IT AS NSFW!" + - "Refer to #release-notes in the Discord for pretty pictures or try it yourself!." + - number: "Texture Optimization" + icon: "" + items: + - "In 2.0.0, we've added the option for Texture Optimization to improve the performance of scenarios such as overwhelmingly big " + - "NOTE: ALL OF THESE ARE OPTIONAL AND DISABLED BY DEFAULT" + - "Within Texture Optimization, you will be able to safely downscale all textures of new downloads around you." + - "This downscale DOES NOT APPLY to DIRECT PAIRS or those who've updated their preferred settings to not be downscaled" + - "The first time this is enabled, you may experience some lag or frame drops, but in the long run, it will help performance." + - "This can be found in Lightless Settings > Performance > Texture Optimization" + - "Like a broken record, please refer to #release-notes in the Discord for more information." + - number: "Character Analysis - The big scary UI no one knew about" + icon: "" + items: + - "We have made the Character Analysis UI more user friendly. This includes a revamp of the look and functionality" + - "You can now see more information about your character and how it affects performance" + - "It will show you the Textures tab by default with an option for \"Other file types\"" + - "You can now choose if you want to BC7/BC5/BC4/BC3/BC1 compress a certain texture." + - "The UI will give you a recommendation on what BC compression to use based on the file." + - "Shows a small preview of what the texture looks like with some general info about it." + - "Shows you how much VRAM you would take up." + - "This can be found in Lightless Settings > Performance > Character Analysis" + - number: "Performance" + icon: "" + items: + - "Moved to the internal object table to have improved overall plugin performance." + - "Compactor is now running on a multi-threaded level instead of single-threaded; This should increase the speed of compacting files." + - "Penumbra Collections are now only made when people are visible, reducing the load on boot-up when having many Syncshells in your list." + - "Pairing system has been revamped to make pausing and unpausing faster, and loading people should be faster as well." + - number: "Miscellaneous Changes and Bugfixes" + icon: "" + items: + - "UI has been updated to look more modern" + - "We have started on file compression for Linux with the option for BTRFS or ZFS but it's not very great yet and will release later." + - "Nameplate colours now use sigs to client structs as an alternative to the Nameplate Handler, also preventing crashes on that from our end." + - "Notifications now work with the \"Enable multi-monitor windows\" settings of Dalamud." + - "Fixed a bug where nothing above the notifications was clickable in certain cases." + - "Added a check that prevents small messages from going below 0 resulting in an ArgumentOutOfRangeException." - name: "v1.12.4" tagline: "Preparation for future features" date: "November 11th 2025" - # be sure to set this every new version - isCurrent: true versions: - number: "Syncshells" icon: "" -- 2.49.1 From ecc1e7107ff25fb6d1bb321793469df2e6912611 Mon Sep 17 00:00:00 2001 From: azyges Date: Wed, 17 Dec 2025 17:18:53 +0900 Subject: [PATCH 103/140] bump submodule --- Penumbra.Api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Penumbra.Api b/Penumbra.Api index d520712..66a11d4 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit d52071290b48a1f2292023675b4b72365aef4cc0 +Subproject commit 66a11d4c886d64da6ecb1a0ec4c8306b99167be1 -- 2.49.1 From 1d212437f5b8981e4ef3e8bfa0454338ae5d68a3 Mon Sep 17 00:00:00 2001 From: azyges Date: Thu, 18 Dec 2025 04:46:44 +0900 Subject: [PATCH 104/140] API14 --- LightlessSync/LightlessSync.csproj | 4 +- .../ActorTracking/ActorObjectService.cs | 2 +- LightlessSync/Services/DalamudUtilService.cs | 4 +- LightlessSync/packages.lock.json | 929 +----------------- OtterGui | 2 +- Penumbra.Api | 2 +- Penumbra.GameData | 2 +- Penumbra.String | 2 +- 8 files changed, 21 insertions(+), 926 deletions(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index b46cccc..ce036e4 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@ - net9.0-windows7.0 + net10.0-windows7.0 x64 enable latest diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index 1813947..f9c4615 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -783,7 +783,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (drawObject == null) return false; - if (gameObject->RenderFlags == 2048) + if ((ushort)gameObject->RenderFlags == 2048) return false; var characterBase = (CharacterBase*)drawObject; diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 253847c..5614505 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -752,7 +752,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber bool isDrawingChanged = false; if ((nint)drawObj != IntPtr.Zero) { - isDrawing = gameObj->RenderFlags == 0b100000000000; + isDrawing = (ushort)gameObj->RenderFlags == 0b100000000000; if (!isDrawing) { isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; @@ -1047,4 +1047,4 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber onExit(); } } -} \ No newline at end of file +} diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index daa1008..c740db4 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -1,7 +1,7 @@ { "version": 1, "dependencies": { - "net9.0-windows7.0": { + "net10.0-windows7.0": { "Blake3": { "type": "Direct", "requested": "[2.0.0, )", @@ -10,15 +10,15 @@ }, "DalamudPackager": { "type": "Direct", - "requested": "[13.1.0, )", - "resolved": "13.1.0", - "contentHash": "XdoNhJGyFby5M/sdcRhnc5xTop9PHy+H50PTWpzLhJugjB19EDBiHD/AsiDF66RETM+0qKUdJBZrNuebn7qswQ==" + "requested": "[14.0.0, )", + "resolved": "14.0.0", + "contentHash": "9c1q/eAeAs82mkQWBOaCvbt3GIQxAIadz5b/7pCXDIy9nHPtnRc+tDXEvKR+M36Wvi7n+qBTevRupkLUQp6DFA==" }, "DotNet.ReproducibleBuilds": { "type": "Direct", - "requested": "[1.2.25, )", - "resolved": "1.2.25", - "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" + "requested": "[1.2.39, )", + "resolved": "1.2.39", + "contentHash": "fcFN01tDTIQqDuTwr1jUQK/geofiwjG5DycJQOnC72i1SsLAk1ELe+apBOuZ11UMQG8YKFZG1FgvjZPbqHyatg==" }, "Downloader": { "type": "Direct", @@ -147,10 +147,7 @@ "FlatSharp.Runtime": { "type": "Transitive", "resolved": "7.9.0", - "contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==", - "dependencies": { - "System.Memory": "4.5.5" - } + "contentHash": "Bm8+WqzEsWNpxqrD5x4x+zQ8dyINlToCreM5FI2oNSfUVc9U9ZB+qztX/jd8rlJb3r0vBSlPwVLpw0xBtPa3Vw==" }, "JetBrains.Annotations": { "type": "Transitive", @@ -191,8 +188,7 @@ "dependencies": { "Microsoft.AspNetCore.Http.Connections.Common": "9.0.3", "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3", - "System.Net.ServerSentEvents": "9.0.3" + "Microsoft.Extensions.Options": "9.0.3" } }, "Microsoft.AspNetCore.Http.Connections.Common": { @@ -211,8 +207,7 @@ "Microsoft.AspNetCore.SignalR.Common": "9.0.3", "Microsoft.AspNetCore.SignalR.Protocols.Json": "9.0.3", "Microsoft.Extensions.DependencyInjection": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "System.Threading.Channels": "9.0.3" + "Microsoft.Extensions.Logging": "9.0.3" } }, "Microsoft.AspNetCore.SignalR.Common": { @@ -526,179 +521,14 @@ "resolved": "1.1.0", "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" - }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, "NETStandard.Library": { "type": "Transitive", "resolved": "1.6.1", "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" + "Microsoft.NETCore.Platforms": "1.1.0" } }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, "SharpDX": { "type": "Transitive", "resolved": "4.2.0", @@ -754,746 +584,11 @@ "SharpDX": "4.2.0" } }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Console": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Diagnostics.Debug": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, "System.Diagnostics.EventLog": { "type": "Transitive", "resolved": "9.0.3", "contentHash": "0nDJBZ06DVdTG2vvCZ4XjazLVaFawdT0pnji23ISX8I8fEOlRJyzH2I0kWiAbCtFwry2Zir4qE4l/GStLATfFw==" }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" - } - }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.5.5", - "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==" - }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.ServerSentEvents": { - "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "Vs/C2V27bjtwLqYag9ATzHilcUn8VQTICre4jSBMGFUeSTxEZffTjb+xZwjcmPsVAjmSZmBI5N7Ezq8UFvqQQg==" - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", - "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Channels": { - "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "Ao0iegVONKYVw0eWxJv0ArtMVfkFjgyyYKtUXru6xX5H95flSZWW3QCavD4PAgwpc0ETP38kGHaYbPzSE7sw2w==" - }, - "System.Threading.Tasks": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - }, "lightlesssync.api": { "type": "Project", "dependencies": { @@ -1516,7 +611,7 @@ "FlatSharp.Compiler": "[7.9.0, )", "FlatSharp.Runtime": "[7.9.0, )", "OtterGui": "[1.0.0, )", - "Penumbra.Api": "[5.12.0, )", + "Penumbra.Api": "[5.13.0, )", "Penumbra.String": "[1.0.6, )" } }, diff --git a/OtterGui b/OtterGui index 1459e2b..6f32364 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 1459e2b8f5e1687f659836709e23571235d4206c +Subproject commit 6f3236453b1edfaa25c8edcc8b39a9d9b2fc18ac diff --git a/Penumbra.Api b/Penumbra.Api index 66a11d4..e4934cc 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 66a11d4c886d64da6ecb1a0ec4c8306b99167be1 +Subproject commit e4934ccca0379f22dadf989ab2d34f30b3c5c7ea diff --git a/Penumbra.GameData b/Penumbra.GameData index 1bb6210..2ff50e6 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 1bb6210d7cba0e3091bbb5a13b901c62e72280e9 +Subproject commit 2ff50e68f7c951f0f8b25957a400a2e32ed9d6dc diff --git a/Penumbra.String b/Penumbra.String index 462afac..0315144 160000 --- a/Penumbra.String +++ b/Penumbra.String @@ -1 +1 @@ -Subproject commit 462afac558becebbe06b4e5be9b1b3c3f5a9b6d6 +Subproject commit 0315144ab5614c11911e2a4dddf436fb18c5d7e3 -- 2.49.1 From d766c2c42e2cb7d52305b186c117e8166ec3d7b8 Mon Sep 17 00:00:00 2001 From: cake Date: Thu, 18 Dec 2025 01:19:26 +0100 Subject: [PATCH 105/140] Added offset, font and fontsize as needed for 14 --- LightlessSync/Utils/SeStringUtils.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index c3e50fd..20e732e 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -588,6 +588,11 @@ public static class SeStringUtils var drawPos = new Vector2(position.X, position.Y + verticalOffset); ImGui.SetCursorScreenPos(drawPos); + + drawParams.ScreenOffset = drawPos; + drawParams.Font = usedFont; + drawParams.FontSize = usedFont.FontSize; + ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); ImGui.SetCursorScreenPos(position); -- 2.49.1 From f7fb609c7189139fe5ec9695bb28dc866fd40139 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 20:38:15 -0600 Subject: [PATCH 106/140] clean up --- PenumbraAPI/packages.lock.json | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 PenumbraAPI/packages.lock.json diff --git a/PenumbraAPI/packages.lock.json b/PenumbraAPI/packages.lock.json deleted file mode 100644 index bd07e56..0000000 --- a/PenumbraAPI/packages.lock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net9.0-windows7.0": { - "DotNet.ReproducibleBuilds": { - "type": "Direct", - "requested": "[1.2.25, )", - "resolved": "1.2.25", - "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" - } - } - } -} \ No newline at end of file -- 2.49.1 From e3c04e31e7e8674028c9b8c4b5c694b8cd6bea3e Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 20:40:24 -0600 Subject: [PATCH 107/140] clean up --- Pictomancy/Pictomancy/packages.lock.json | 970 ----------------------- 1 file changed, 970 deletions(-) delete mode 100644 Pictomancy/Pictomancy/packages.lock.json diff --git a/Pictomancy/Pictomancy/packages.lock.json b/Pictomancy/Pictomancy/packages.lock.json deleted file mode 100644 index 95a3cd4..0000000 --- a/Pictomancy/Pictomancy/packages.lock.json +++ /dev/null @@ -1,970 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net9.0-windows7.0": { - "DotNet.ReproducibleBuilds": { - "type": "Direct", - "requested": "[1.2.25, )", - "resolved": "1.2.25", - "contentHash": "xCXiw7BCxHJ8pF6wPepRUddlh2dlQlbr81gXA72hdk4FLHkKXas7EH/n+fk5UCA/YfMqG1Z6XaPiUjDbUNBUzg==" - }, - "SharpDX.D3DCompiler": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "Rnsd6Ilp127xbXqhTit8WKFQUrXwWxqVGpglyWDNkIBCk0tWXNQEjrJpsl0KAObzyZaa33+EXAikLVt5fnd3GA==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "SharpDX": "4.2.0" - } - }, - "SharpDX.Direct2D1": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "Qs8LzDMaQf1u3KB8ArHu9pDv6itZ++QXs99a/bVAG+nKr0Hx5NG4mcN5vsfE0mVR2TkeHfeUm4PksRah6VUPtA==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "SharpDX": "4.2.0", - "SharpDX.DXGI": "4.2.0" - } - }, - "SharpDX.Direct3D11": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "oTm/iT5X/IIuJ8kNYP+DTC/MhBhqtRF5dbgPPFgLBdQv0BKzNTzXQQXd7SveBFjQg6hXEAJ2jGCAzNYvGFc9LA==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "SharpDX": "4.2.0", - "SharpDX.DXGI": "4.2.0" - } - }, - "SharpDX.Mathematics": { - "type": "Direct", - "requested": "[4.2.0, )", - "resolved": "4.2.0", - "contentHash": "R2pcKLgdsP9p5WyTjHmGOZ0ka0zASAZYc6P4L6rSvjYhf6klGYbent7MiVwbkwkt9dD44p5brjy5IwAnVONWGw==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "SharpDX": "4.2.0" - } - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "Microsoft.NETCore.Targets": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "aOZA3BWfz9RXjpzt0sRJJMjAscAUm3Hoa4UWAfceV9UTYxgwZ1lZt5nO2myFf+/jetYQo4uTP7zS8sJY67BBxg==" - }, - "Microsoft.Win32.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "9ZQKCWxH7Ijp9BfahvL2Zyf1cJIk8XYLF6Yjzr2yi0b2cOut/HQ31qf1ThHAgCc3WiZMdnWcfJCgN82/0UunxA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "NETStandard.Library": { - "type": "Transitive", - "resolved": "1.6.1", - "contentHash": "WcSp3+vP+yHNgS8EV5J7pZ9IRpeDuARBPN28by8zqff1wJQXm26PVU8L3/fYLBJVU7BtDyqNVWq2KlCVvSSR4A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.Win32.Primitives": "4.3.0", - "System.AppContext": "4.3.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Console": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.Compression.ZipFile": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Linq": "4.3.0", - "System.Linq.Expressions": "4.3.0", - "System.Net.Http": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Net.Sockets": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.InteropServices.RuntimeInformation": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Timer": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0", - "System.Xml.XDocument": "4.3.0" - } - }, - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "HdSSp5MnJSsg08KMfZThpuLPJpPwE5hBXvHwoKWosyHHfe8Mh5WKT0ylEOf6yNzX6Ngjxe4Whkafh5q7Ymac4Q==" - }, - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+yH1a49wJMy8Zt4yx5RhJrxO/DBDByAiCzNwiETI+1S4mPdCu0OY4djdciC7Vssk0l22wQaDLrXxXkp+3+7bVA==" - }, - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c3YNH1GQJbfIPJeCnr4avseugSqPrxwIqzthYyZDN6EuOyNOzq+y2KSUfRcXauya1sF4foESTgwM5e1A8arAKw==" - }, - "runtime.native.System": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "c/qWt2LieNZIj1jGnVNsE2Kl23Ya2aSTBuXMD6V7k9KWr6l16Tqdwq+hJScEpWER9753NWC8h96PaVNY5Ld7Jw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "INBPonS5QPEgn7naufQFXJEp3zX6L4bwHgJ/ZH78aBTpeNfQMtf7C6VrAFhlq2xxWBveIOWyFzQjJ8XzHMhdOQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZVuZJqnnegJhd2k/PtAbbIcZ3aZeITq3sj06oKfMBSfphW3HDmk/t4ObvbOk/JA/swGR0LNqMksAh/f7gpTROg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DloMk88juo0OuOWr56QG7MNchmafTLYWvABy36izkrLI5VledI0rq28KGs1i9wbpeT9NPQrx/wTf8U2vazqQ3Q==", - "dependencies": { - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": "4.3.0" - } - }, - "runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "NS1U+700m4KFRHR5o4vo9DSlTmlCKu/u7dtE5sUHVIPB+xpXxYQvgBgA6wEIeCz6Yfn0Z52/72WYsToCEPJnrw==", - "dependencies": { - "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.23-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.fedora.24-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0", - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "runtime.opensuse.13.2-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "b3pthNgxxFcD+Pc0WSEoC0+md3MyhRS6aCEeenvNE3Fdw1HyJ18ZhRFVJJzIeR/O/jpxPboB805Ho0T3Ul7w8A==" - }, - "runtime.opensuse.42.1-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KeLz4HClKf+nFS7p/6Fi/CqyLXh81FpiGzcmuS8DGi9lUqSnZ6Es23/gv2O+1XVGfrbNmviF7CckBpavkBoIFQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.Apple": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kVXCuMTrTlxq4XOOMAysuNwsXWpYeboGddNGpIgNSZmv1b6r/s/DPk0fYMB7Q5Qo4bY68o48jt4T4y5BVecbCQ==" - }, - "runtime.osx.10.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X7IdhILzr4ROXd8mI1BUCQMSHSQwelUlBjF1JyTKCjXaOGn2fB4EKBxQbCK2VjO3WaWIdlXZL3W6TiIVnrhX4g==" - }, - "runtime.rhel.7-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "nyFNiCk/r+VOiIqreLix8yN+q3Wga9+SE8BCgkf+2BwEKiNx6DyvFjCgkfV743/grxv8jHJ8gUK4XEQw7yzRYg==" - }, - "runtime.ubuntu.14.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ytoewC6wGorL7KoCAvRfsgoJPJbNq+64k2SqW6JcOAebWsFUvCCYgfzQMrnpvPiEl4OrblUlhF2ji+Q1+SVLrQ==" - }, - "runtime.ubuntu.16.04-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "I8bKw2I8k58Wx7fMKQJn2R8lamboCAiHfHeV/pS65ScKWMMI0+wJkLYlEKvgW1D/XvSl/221clBoR2q9QNNM7A==" - }, - "runtime.ubuntu.16.10-x64.runtime.native.System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VB5cn/7OzUfzdnC8tqAIMQciVLiq2epm2NrAm1E9OjNRyG4lVhfR61SMcLizejzQP8R8Uf/0l5qOIbUEi+RdEg==" - }, - "SharpDX": { - "type": "Transitive", - "resolved": "4.2.0", - "contentHash": "3pv0LFMvfK/dv1qISJnn8xBeeT6R/FRvr0EV4KI2DGsL84Qlv6P7isWqxGyU0LCwlSVCJN3jgHJ4Bl0KI2PJww==", - "dependencies": { - "NETStandard.Library": "1.6.1" - } - }, - "SharpDX.DXGI": { - "type": "Transitive", - "resolved": "4.2.0", - "contentHash": "UjKqkgWc8U+SP+j3LBzFP6OB6Ntapjih7Xo+g1rLcsGbIb5KwewBrBChaUu7sil8rWoeVU/k0EJd3SMN4VqNZw==", - "dependencies": { - "NETStandard.Library": "1.6.1", - "SharpDX": "4.2.0" - } - }, - "System.AppContext": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "fKC+rmaLfeIzUhagxY17Q9siv/sPrjjKcfNg1Ic8IlQkZLipo8ljcaZQu4VtI4Jqbzjc2VTjzGLF6WmsRXAEgA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ratu44uTIHgeBeI0dE8DWvmXVBSo4u7ozRZZHOMmK/JPpYyo0dAfgSiHlpiObMQ5lEtEyIXA40sKRYg5J6A8uQ==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Collections": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3Dcj85/TBdVpL5Zr+gEEBUuFe2icOnLalmEh9hfck1PTYbbyWuZgh4fmm2ysCLTrqLQw6t3TgTyJ+VLp+Qb+Lw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Collections.Concurrent": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ztl69Xp0Y/UXCL+3v3tEU+lIy+bvjKNUmopn1wep/a291pVPK7dxBd6T7WnlQqRog+d1a/hSsgRsmFnIBKTPLQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Console": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "DHDrIxiqk1h03m6khKWV2X8p/uvN79rgSqpilL6uzpmSfxfU5ng8VcPtW4qsDsQDHiTv6IPV9TmD5M/vElPNLg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Diagnostics.Debug": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "ZUhUOdqmaG5Jk3Xdb8xi5kIyQYAA4PnTNlHx1mu9ZY3qv4ELIdKbnL/akbGaKi2RnNUWaZsAs31rvzFdewTj2g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "tD6kosZnTAGdrEa0tZSuFyunMbt/5KYDnHdndJYGqZoNy00XVXyACd5d6KnE1YgYv3ne2CjtAfNXo/fwEhnKUA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Diagnostics.Tools": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "UUvkJfSYJMM6x527dJg2VyWPSRqIVB0Z7dbjHst1zmwTXz5CcXSYJFWRpuigfbO1Lf7yfZiIaEUesfnl/g5EyA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Diagnostics.Tracing": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rswfv0f/Cqkh78rA5S8eN8Neocz234+emGCtTF3lxPY96F+mmmUen6tbn0glN6PMvlKQb9bPAY5e9u7fgPTkKw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "kYdVd2f2PAdFGblzFswE4hkNANJBKRmsfa2X5LG2AcWE1c7/4t0pYae1L8vfZ5xvE2nK/R9JprtToA61OSHWIg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Calendars": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GUlBtdOWT4LTV3I+9/PJW+56AnnChTaOqqTLFtdmype/L500M2LIyXgmtd9X2P2VOkmJd5c67H5SaC2QcL1bFA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Globalization.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "FhKmdR6MPG+pxow6wGtNAWdZh7noIOpdD5TwQ3CprzgIE1bBBoim0vbR1+AWsWjQmU7zXHgQo4TWSP6lCeiWcQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0" - } - }, - "System.IO": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3qjaHvxQPDpSOYICjUoTsmoq5u6QJAFRUITgeT/4gqkF1bajbSmb1kwSxEA8AHlofqgcKJcM8udgieRNhaJ5Cg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.Compression": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YHndyoiV90iu4iKG115ibkhrG+S3jBm8Ap9OwoUAzO5oPDAWcr0SFwQFm0HjM8WkEZWo0zvLTyLmbvTkW1bXgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Buffers": "4.3.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.IO.Compression": "4.3.0" - } - }, - "System.IO.Compression.ZipFile": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "G4HwjEsgIwy3JFBduZ9quBkAu+eUwjIdJleuNSgmUojbH6O3mlvEIme+GHx/cLlTAPcrnnL7GqvB9pTlWRfhOg==", - "dependencies": { - "System.Buffers": "4.3.0", - "System.IO": "4.3.0", - "System.IO.Compression": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.IO.FileSystem": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "3wEMARTnuio+ulnvi+hkRNROYwa1kylvYahhcLk4HSoVdl+xxTFVeVlYOfLwrDPImGls0mDqbMhrza8qnWPTdA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.IO.FileSystem.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "6QOb2XFLch7bEc4lIcJH49nJN2HV+OC3fHDgsLVsBVBk3Y4hFAnOBGzJ2lUu7CyDDFo9IBWkSsnbkT6IBwwiMw==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Linq": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5DbqIUpsDp0dFftytzuMmc0oeMdQwjcP/EWxsksIz/w1TcFRkZ3yKKz0PqiYFMmEwPSWw+qNVqD7PJ889JzHbw==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Linq.Expressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "PGKkrd2khG4CnlyJwxwwaWWiSiWFNBGlgXvJpeO0xCXrZ89ODrQ6tjEWS/kOqZ8GwEOUATtKtzp1eRgmYNfclg==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Linq": "4.3.0", - "System.ObjectModel": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Emit.Lightweight": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Reflection.TypeExtensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Net.Http": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "sYg+FtILtRQuYWSIAuNOELwVuVsxVyJGWQyOnlAzhV4xvhyFnON1bAzYYC+jjRW8JREM45R0R5Dgi8MTC5sEwA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.DiagnosticSource": "4.3.0", - "System.Diagnostics.Tracing": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Extensions": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Security.Cryptography.X509Certificates": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Net.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "qOu+hDwFwoZPbzPvwut2qATe3ygjeQBDQj91xlsaqGFQUI5i4ZnZb8yyQuLGpDGivEPIt8EJkd1BVzVoP31FXA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Net.Sockets": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "m6icV6TqQOAdgt5N/9I5KNpjom/5NFtkmGseEH+AK/hny8XrytLH3+b5M8zL/Ycg3fhIocFpUMyl/wpFnVRvdw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Net.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.ObjectModel": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "bdX+80eKv9bN6K4N+d77OankKHGn6CH711a6fcOpMQu2Fckp/Ft4L/kW9WznHpyR0NRAvJutzOMHNNlBGvxQzQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Reflection": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "KMiAFoW7MfJGa9nDFNcfu+FpEdiHpWgTcS2HdMpDvt9saK3y/G4GwprPyzqjFH9NTaGPQeWNHU+iDlDILj96aQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "228FG0jLcIwTVJyz8CLFKueVqQK36ANazUManGaJHkO0icjiIypKW7YLWLIWahyIkdh5M7mV2dJepllLyA1SKg==", - "dependencies": { - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.ILGeneration": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "59tBslAk9733NXLrUJrwNZEzbMAcu8k344OYo+wfSVygcgZ9lgBdGIzH/nrg3LYhXceynyvTc8t5/GD4Ri0/ng==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Emit.Lightweight": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "oadVHGSMsTmZsAF864QYN1t1QzZjIcuKU3l2S9cZOwDdDueNTrqq1yRj7koFfIGEnKpt6NjpL3rOzRhs4ryOgA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Emit.ILGeneration": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "rJkrJD3kBI5B712aRu4DpSIiHRtr6QlfZSQsb0hYHrDCZORXCFjQfoipo2LaMUHoT9i1B7j7MnfaEKWDFmFQNQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5RXItQz5As4xN2/YUDxdpsEkMhvw3e6aNveFXUn4Hl/udNTCNhnKp8lT9fnc3MhvGKh1baak5CovpuQUXHAlIA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Reflection.TypeExtensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7u6ulLcZbyxB5Gq0nMkQttcdBTx57ibzw+4IOXEfR+sXYQoHvjW5LTLyNr8O22UIMrqYbchJQJnos4eooYzYJA==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Resources.ResourceManager": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "/zrcPkkWdZmI4F92gL/TPumP98AVDu/Wxr3CSJGQQ+XN6wbRZcyfSKVoPo17ilb3iOr0cCRqJInGwNMolqhS8A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Globalization": "4.3.0", - "System.Reflection": "4.3.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "JufQi0vPQ0xGnAczR13AUFglDyVYt4Kqnz1AZaiKZ5+GICq0/1MH/mO/eAJHt/mHW1zjKBJd7kV26SrxddAhiw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0" - } - }, - "System.Runtime.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "guW0uK0fn5fcJJ1tJVXYd7/1h5F+pea1r7FLSOz/f8vPEqbR2ZAknuRDvTQ8PzAilDveOxNjSfr0CHfIQfFk8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.Handles": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "OKiSUN7DmTWeYb3l51A7EYaeNMnvxwE249YtZz7yooT4gOZhmTjIn48KgSsw2k2lYdLgTKNJw/ZIfSElwDRVgg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Runtime.InteropServices": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "uv1ynXqiMK8mp1GM3jDqPCFN66eJ5w5XNomaK2XD+TuCroNTLFGeZ+WCmBMcBDyTFKou3P6cR6J/QsaqDp7fGQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Reflection": "4.3.0", - "System.Reflection.Primitives": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Handles": "4.3.0" - } - }, - "System.Runtime.InteropServices.RuntimeInformation": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "cbz4YJMqRDR7oLeMRbdYv7mYzc++17lNhScCX0goO2XpGWdvAt60CGN+FHdePUEHCe/Jy9jUlvNAiNdM+7jsOw==", - "dependencies": { - "System.Reflection": "4.3.0", - "System.Reflection.Extensions": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0" - } - }, - "System.Runtime.Numerics": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "yMH+MfdzHjy17l2KESnPiF2dwq7T+xLnSJar7slyimAkUh/gTrS9/UQOtv7xarskJ2/XDSNvfLGOBQPjL7PaHQ==", - "dependencies": { - "System.Globalization": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0" - } - }, - "System.Security.Cryptography.Algorithms": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "W1kd2Y8mYSCgc3ULTAZ0hOP2dSdG5YauTb1089T0/kRcN2MpSAW1izOFROrJgxSlMn3ArsgHXagigyi+ibhevg==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.Apple": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Cng": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "03idZOqFlsKRL4W+LuCpJ6dBYDUWReug6lZjBa3uJWnk5sPCUXckocevTaUA8iT/MFSrY/2HXkOt753xQ/cf8g==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Security.Cryptography.Csp": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "X4s/FCkEUnRGnwR3aSfVIkldBmtURMhmexALNTwpjklzxWU7yjMk7GHLKOZTNkgnWnE0q7+BCf9N2LVRWxewaA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0" - } - }, - "System.Security.Cryptography.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "1DEWjZZly9ae9C79vFwqaO5kaOlI5q+3/55ohmq/7dpDyDfc8lYe7YVxJUZ5MF/NtbkRjwFRo14yM4OEo9EmDw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Collections.Concurrent": "4.3.0", - "System.Linq": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.OpenSsl": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "h4CEgOgv5PKVF/HwaHzJRiVboL2THYCou97zpmhjghx5frc7fIvlkY1jL+lnIQyChrJDMNEXS6r7byGif8Cy4w==", - "dependencies": { - "System.Collections": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Security.Cryptography.Primitives": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==", - "dependencies": { - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Security.Cryptography.X509Certificates": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "t2Tmu6Y2NtJ2um0RtcuhP7ZdNNxXEgUm2JeoA/0NvlMjAhKCnM1NX07TDl3244mVp3QU6LPEhT3HTtH1uF7IYw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.Globalization.Calendars": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.Handles": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Runtime.Numerics": "4.3.0", - "System.Security.Cryptography.Algorithms": "4.3.0", - "System.Security.Cryptography.Cng": "4.3.0", - "System.Security.Cryptography.Csp": "4.3.0", - "System.Security.Cryptography.Encoding": "4.3.0", - "System.Security.Cryptography.OpenSsl": "4.3.0", - "System.Security.Cryptography.Primitives": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "runtime.native.System": "4.3.0", - "runtime.native.System.Net.Http": "4.3.0", - "runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0" - } - }, - "System.Text.Encoding": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "BiIg+KWaSDOITze6jGQynxg64naAPtqGHBwDrLaCtixsa5bKiR8dpPOHA7ge3C0JJQizJE+sfkz1wV+BAKAYZw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Text.Encoding.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "YVMK0Bt/A43RmwizJoZ22ei2nmrhobgeiYwFzC4YAN+nue8RF6djXDMog0UCn+brerQoYVyaS+ghy9P/MUVcmw==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0", - "System.Text.Encoding": "4.3.0" - } - }, - "System.Text.RegularExpressions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "RpT2DA+L660cBt1FssIE9CAGpLFdFPuheB7pLpKpn6ZXNby7jDERe8Ua/Ne2xGiwLVG2JOqziiaVCGDon5sKFA==", - "dependencies": { - "System.Runtime": "4.3.0" - } - }, - "System.Threading": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "VkUS0kOBcUf3Wwm0TSbrevDDZ6BlM+b/HRiapRFWjM5O0NS0LviG0glKmFK+hhPDd1XFeSdU1GmlLhb2CoVpIw==", - "dependencies": { - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Tasks": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "LbSxKEdOUhVe8BezB/9uOGGppt+nZf6e1VFyw6v3DN6lqitm0OSn2uXMOdtP0M3W4iMcqcivm2J6UgqiwwnXiA==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "npvJkVKl5rKXrtl1Kkm6OhOUaYGEiF9wFbppFRWSMoApKzt2PiPHT2Bb8a5sAWxprvdOAtvaARS9QYMznEUtug==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Runtime": "4.3.0", - "System.Threading.Tasks": "4.3.0" - } - }, - "System.Threading.Timer": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "Z6YfyYTCg7lOZjJzBjONJTFKGN9/NIYKSxhU5GRd+DTwHSZyvWp1xuI5aR+dLg+ayyC5Xv57KiY4oJ0tMO89fQ==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0", - "Microsoft.NETCore.Targets": "1.1.0", - "System.Runtime": "4.3.0" - } - }, - "System.Xml.ReaderWriter": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "GrprA+Z0RUXaR4N7/eW71j1rgMnEnEVlgii49GZyAjTH7uliMnrOU3HNFBr6fEDBCJCIdlVNq9hHbaDR621XBA==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.IO.FileSystem": "4.3.0", - "System.IO.FileSystem.Primitives": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Runtime.InteropServices": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Text.Encoding.Extensions": "4.3.0", - "System.Text.RegularExpressions": "4.3.0", - "System.Threading.Tasks": "4.3.0", - "System.Threading.Tasks.Extensions": "4.3.0" - } - }, - "System.Xml.XDocument": { - "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "5zJ0XDxAIg8iy+t4aMnQAu0MqVbqyvfoUVl1yDV61xdo3Vth45oA2FoY4pPkxYAH5f8ixpmTqXeEIya95x0aCQ==", - "dependencies": { - "System.Collections": "4.3.0", - "System.Diagnostics.Debug": "4.3.0", - "System.Diagnostics.Tools": "4.3.0", - "System.Globalization": "4.3.0", - "System.IO": "4.3.0", - "System.Reflection": "4.3.0", - "System.Resources.ResourceManager": "4.3.0", - "System.Runtime": "4.3.0", - "System.Runtime.Extensions": "4.3.0", - "System.Text.Encoding": "4.3.0", - "System.Threading": "4.3.0", - "System.Xml.ReaderWriter": "4.3.0" - } - } - } - } -} \ No newline at end of file -- 2.49.1 From 35e35591f50ddec079373db79f7a396f453403b6 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 23:03:49 -0600 Subject: [PATCH 108/140] Fix sestrings --- LightlessSync/LightlessSync.csproj | 2 +- LightlessSync/Utils/SeStringUtils.cs | 2 ++ LightlessSync/packages.lock.json | 43 ++++++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index ce036e4..11b9c14 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -37,7 +37,7 @@ - + diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 20e732e..186db83 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -626,6 +626,7 @@ public static class SeStringUtils var measureParams = new SeStringDrawParams { Font = usedFont, + FontSize = usedFont.FontSize, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue }; @@ -647,6 +648,7 @@ public static class SeStringUtils var drawParams = new SeStringDrawParams { Font = usedFont, + FontSize = usedFont.FontSize, Color = 0xFFFFFFFF, WrapWidth = float.MaxValue, TargetDrawList = drawList, diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index c740db4..baae0a0 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -31,9 +31,9 @@ }, "Glamourer.Api": { "type": "Direct", - "requested": "[2.6.0, )", - "resolved": "2.6.0", - "contentHash": "zysCZgNBRm3k3qvibyw/31MmEckX0Uh0ZsT+Sax3ZHnYIRELr9Qhbz3cjJz7u0RHGIrNJiRpktu/LxgHEqDItw==" + "requested": "[2.7.0, )", + "resolved": "2.7.0", + "contentHash": "H4yRNEhdSQ+YkZlnE7qRM67GaNieb9Xe9Vpj3rvHvcSB0eWgMF1nHqCvkBNb4L38AV4WyWTzwtXh6+Rv5GuVTw==" }, "K4os.Compression.LZ4.Legacy": { "type": "Direct", @@ -139,6 +139,19 @@ "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" }, + "BCnEncoder.Net": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg==" + }, "FlatSharp.Compiler": { "type": "Transitive", "resolved": "7.9.0", @@ -159,6 +172,23 @@ "resolved": "1.3.8", "contentHash": "LhwlPa7c1zs1OV2XadMtAWdImjLIsqFJPoRcIWAadSRn0Ri1DepK65UbWLPmt4riLqx2d40xjXRk0ogpqNtK7g==" }, + "Lumina": { + "type": "Transitive", + "resolved": "7.1.0", + "contentHash": "7zjreOpMXOhf+m5uTefoLZO6Mt3y68XhK036eDOS5JJ8yuIjAgFteWkzdCRcss8+PH5skMnDMDUZoTJJefQCtg==", + "dependencies": { + "BCnEncoder.Net": "2.2.1", + "Microsoft.Extensions.ObjectPool": "10.0.0" + } + }, + "Lumina.Excel": { + "type": "Transitive", + "resolved": "7.4.0", + "contentHash": "RlJT0Xc1bltPrnsy1ZlLsrTDNOnQNfyxDT37xkCW7mr3w9IjhklsL9J+/+aM31G1Uv0g01AjwHm6RQKdKYi6HQ==", + "dependencies": { + "Lumina": "7.1.0" + } + }, "MessagePack": { "type": "Transitive", "resolved": "2.5.187", @@ -455,6 +485,11 @@ "Microsoft.Extensions.Primitives": "9.0.3" } }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "bpeCq0IYmVLACyEUMzFIOQX+zZUElG1t+nu1lSxthe7B+1oNYking7b91305+jNB6iwojp9fqTY9O+Nh7ULQxg==" + }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "9.0.3", @@ -610,6 +645,8 @@ "dependencies": { "FlatSharp.Compiler": "[7.9.0, )", "FlatSharp.Runtime": "[7.9.0, )", + "Lumina": "[7.1.0, )", + "Lumina.Excel": "[7.4.0, )", "OtterGui": "[1.0.0, )", "Penumbra.Api": "[5.13.0, )", "Penumbra.String": "[1.0.6, )" -- 2.49.1 From 99b49762bb9a4eff8afe42b5d5eb66bf2ad62c04 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 23:06:07 -0600 Subject: [PATCH 109/140] why --- LightlessSync/packages.lock.json | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index baae0a0..9e955c6 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -172,23 +172,6 @@ "resolved": "1.3.8", "contentHash": "LhwlPa7c1zs1OV2XadMtAWdImjLIsqFJPoRcIWAadSRn0Ri1DepK65UbWLPmt4riLqx2d40xjXRk0ogpqNtK7g==" }, - "Lumina": { - "type": "Transitive", - "resolved": "7.1.0", - "contentHash": "7zjreOpMXOhf+m5uTefoLZO6Mt3y68XhK036eDOS5JJ8yuIjAgFteWkzdCRcss8+PH5skMnDMDUZoTJJefQCtg==", - "dependencies": { - "BCnEncoder.Net": "2.2.1", - "Microsoft.Extensions.ObjectPool": "10.0.0" - } - }, - "Lumina.Excel": { - "type": "Transitive", - "resolved": "7.4.0", - "contentHash": "RlJT0Xc1bltPrnsy1ZlLsrTDNOnQNfyxDT37xkCW7mr3w9IjhklsL9J+/+aM31G1Uv0g01AjwHm6RQKdKYi6HQ==", - "dependencies": { - "Lumina": "7.1.0" - } - }, "MessagePack": { "type": "Transitive", "resolved": "2.5.187", -- 2.49.1 From 8b75063b9d44a254879f374132b6080c7904cf57 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 23:07:28 -0600 Subject: [PATCH 110/140] packagejson shenanigans --- LightlessSync/packages.lock.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 9e955c6..830a83c 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -628,8 +628,6 @@ "dependencies": { "FlatSharp.Compiler": "[7.9.0, )", "FlatSharp.Runtime": "[7.9.0, )", - "Lumina": "[7.1.0, )", - "Lumina.Excel": "[7.4.0, )", "OtterGui": "[1.0.0, )", "Penumbra.Api": "[5.13.0, )", "Penumbra.String": "[1.0.6, )" -- 2.49.1 From 5e2afc8bfe6a5412d82f46ffadd862acff1d0be9 Mon Sep 17 00:00:00 2001 From: defnotken Date: Wed, 17 Dec 2025 23:19:18 -0600 Subject: [PATCH 111/140] Fixed Sestring font + offset --- LightlessSync/Utils/SeStringUtils.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 186db83..1c71204 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -545,12 +545,15 @@ public static class SeStringUtils { drawList ??= ImGui.GetWindowDrawList(); + var usedFont = font ?? ImGui.GetFont(); var drawParams = new SeStringDrawParams { - Font = font ?? ImGui.GetFont(), + Font = usedFont, + FontSize = usedFont.FontSize, Color = ImGui.GetColorU32(ImGuiCol.Text), WrapWidth = wrapWidth, - TargetDrawList = drawList + TargetDrawList = drawList, + ScreenOffset = ImGui.GetCursorScreenPos() }; ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); -- 2.49.1 From 4ffc2247b266b0093baeecbbf1351b9526f8321c Mon Sep 17 00:00:00 2001 From: azyges Date: Thu, 18 Dec 2025 20:49:38 +0900 Subject: [PATCH 112/140] adjust visibility flags, improve chat functionality, bump submodules --- LightlessAPI | 2 +- .../PlayerData/Handlers/GameObjectHandler.cs | 5 +- .../ActorTracking/ActorObjectService.cs | 2 +- LightlessSync/Services/Chat/ChatModels.cs | 15 +- .../Services/Chat/ZoneChatService.cs | 84 +++++-- LightlessSync/Services/DalamudUtilService.cs | 5 +- LightlessSync/Services/Mediator/Messages.cs | 1 - .../Profiles/LightlessProfileManager.cs | 7 +- LightlessSync/UI/StandaloneProfileUi.cs | 17 +- LightlessSync/UI/ZoneChatUi.cs | 230 +++++++++++++----- .../SignalR/ApIController.Functions.Users.cs | 6 +- ffxiv_pictomancy | 2 +- 12 files changed, 281 insertions(+), 95 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index dfb0594..efc0ef0 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 +Subproject commit efc0ef09f9a3bf774f5e946a3b5e473865338be2 diff --git a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs index 829c737..65709d1 100644 --- a/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs +++ b/LightlessSync/PlayerData/Handlers/GameObjectHandler.cs @@ -5,6 +5,7 @@ using LightlessSync.Services.Mediator; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using static FFXIVClientStructs.FFXIV.Client.Game.Character.DrawDataContainer; +using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Handlers; @@ -376,8 +377,8 @@ public sealed class GameObjectHandler : DisposableMediatorSubscriberBase, IHighP { if (Address == IntPtr.Zero) return DrawCondition.ObjectZero; if (DrawObjectAddress == IntPtr.Zero) return DrawCondition.DrawObjectZero; - var renderFlags = (((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags) != 0x0; - if (renderFlags) return DrawCondition.RenderFlags; + var visibilityFlags = ((FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject*)Address)->RenderFlags; + if (visibilityFlags != VisibilityFlags.None) return DrawCondition.RenderFlags; if (ObjectKind == ObjectKind.Player) { diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index f9c4615..c76d6dd 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -783,7 +783,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable if (drawObject == null) return false; - if ((ushort)gameObject->RenderFlags == 2048) + if ((gameObject->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None) return false; var characterBase = (CharacterBase*)drawObject; diff --git a/LightlessSync/Services/Chat/ChatModels.cs b/LightlessSync/Services/Chat/ChatModels.cs index ba89084..0f35f7c 100644 --- a/LightlessSync/Services/Chat/ChatModels.cs +++ b/LightlessSync/Services/Chat/ChatModels.cs @@ -3,10 +3,21 @@ using LightlessSync.API.Dto.Chat; namespace LightlessSync.Services.Chat; public sealed record ChatMessageEntry( - ChatMessageDto Payload, + ChatMessageDto? Payload, string DisplayName, bool FromSelf, - DateTime ReceivedAtUtc); + DateTime ReceivedAtUtc, + ChatSystemEntry? SystemMessage = null) +{ + public bool IsSystem => SystemMessage is not null; +} + +public enum ChatSystemEntryType +{ + ZoneSeparator +} + +public sealed record ChatSystemEntry(ChatSystemEntryType Type, string? ZoneName); public readonly record struct ChatChannelSnapshot( string Key, diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 8e86b49..55009ab 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -240,8 +240,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } } - public Task ResolveParticipantAsync(ChatChannelDescriptor descriptor, string token) - => _apiController.ResolveChatParticipant(new ChatParticipantResolveRequestDto(descriptor, token)); + public async Task SetParticipantMuteAsync(ChatChannelDescriptor descriptor, string token, bool mute) + { + if (string.IsNullOrWhiteSpace(token)) + return false; + + try + { + await _apiController.SetChatParticipantMute(new ChatParticipantMuteRequestDto(descriptor, token, mute)).ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to update chat participant mute state"); + return false; + } + } public Task ReportMessageAsync(ChatChannelDescriptor descriptor, string messageId, string reason, string? additionalContext) { @@ -534,6 +548,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } bool shouldForceSend; + ChatMessageEntry? zoneSeparatorEntry = null; using (_sync.EnterScope()) { @@ -544,11 +559,24 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.IsAvailable = _chatEnabled; state.StatusText = _chatEnabled ? null : "Chat services disabled"; + var previousDescriptor = _lastZoneDescriptor; + var zoneChanged = previousDescriptor.HasValue && !ChannelDescriptorsMatch(previousDescriptor.Value, descriptor.Value); + _activeChannelKey = ZoneChannelKey; - shouldForceSend = force || !_lastZoneDescriptor.HasValue || !ChannelDescriptorsMatch(_lastZoneDescriptor.Value, descriptor.Value); + shouldForceSend = force || !previousDescriptor.HasValue || zoneChanged; + if (zoneChanged && state.Messages.Any(m => !m.IsSystem)) + { + zoneSeparatorEntry = AddZoneSeparatorLocked(state, definition.Value.DisplayName); + } + _lastZoneDescriptor = descriptor; } + if (zoneSeparatorEntry is not null) + { + Mediator.Publish(new ChatChannelMessageAdded(ZoneChannelKey, zoneSeparatorEntry)); + } + PublishChannelListChanged(); await SendPresenceAsync(descriptor.Value, territoryId, isActive: true, force: shouldForceSend).ConfigureAwait(false); } @@ -561,7 +589,6 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private async Task LeaveCurrentZoneAsync(bool force, ushort territoryId) { ChatChannelDescriptor? descriptor = null; - bool clearedHistory = false; using (_sync.EnterScope()) { @@ -570,15 +597,6 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS if (_channels.TryGetValue(ZoneChannelKey, out var state)) { - if (state.Messages.Count > 0) - { - state.Messages.Clear(); - state.HasUnread = false; - state.UnreadCount = 0; - _lastReadCounts[ZoneChannelKey] = 0; - clearedHistory = true; - } - state.IsConnected = _isConnected; state.IsAvailable = false; state.StatusText = !_chatEnabled @@ -593,11 +611,6 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } } - if (clearedHistory) - { - PublishHistoryCleared(ZoneChannelKey); - } - PublishChannelListChanged(); if (descriptor.HasValue) @@ -1007,6 +1020,39 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return new ChatMessageEntry(dto, displayName, fromSelf, DateTime.UtcNow); } + private ChatMessageEntry AddZoneSeparatorLocked(ChatChannelState state, string zoneDisplayName) + { + var separator = new ChatMessageEntry( + null, + string.Empty, + false, + DateTime.UtcNow, + new ChatSystemEntry(ChatSystemEntryType.ZoneSeparator, zoneDisplayName)); + + state.Messages.Add(separator); + if (state.Messages.Count > MaxMessageHistory) + { + state.Messages.RemoveAt(0); + } + + if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) + { + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[ZoneChannelKey] = state.Messages.Count; + } + else if (_lastReadCounts.TryGetValue(ZoneChannelKey, out var readCount)) + { + _lastReadCounts[ZoneChannelKey] = readCount + 1; + } + else + { + _lastReadCounts[ZoneChannelKey] = state.Messages.Count; + } + + return separator; + } + private string ResolveDisplayName(ChatMessageDto dto, bool fromSelf) { var isZone = dto.Channel.Type == ChatChannelType.Zone; @@ -1070,8 +1116,6 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private void PublishChannelListChanged() => Mediator.Publish(new ChatChannelsUpdated()); - private void PublishHistoryCleared(string channelKey) => Mediator.Publish(new ChatChannelHistoryCleared(channelKey)); - private static IEnumerable EnumerateTerritoryKeys(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 5614505..06d480b 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -26,6 +26,7 @@ using System.Runtime.CompilerServices; using System.Text; using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; using GameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject; +using VisibilityFlags = FFXIVClientStructs.FFXIV.Client.Game.Object.VisibilityFlags; namespace LightlessSync.Services; @@ -707,7 +708,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber const int tick = 250; int curWaitTime = 0; _logger.LogTrace("RenderFlags: {flags}", obj->RenderFlags.ToString("X")); - while (obj->RenderFlags != 0x00 && curWaitTime < timeOut) + while (obj->RenderFlags != VisibilityFlags.None && curWaitTime < timeOut) { _logger.LogTrace($"Waiting for gpose actor to finish drawing"); curWaitTime += tick; @@ -752,7 +753,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber bool isDrawingChanged = false; if ((nint)drawObj != IntPtr.Zero) { - isDrawing = (ushort)gameObj->RenderFlags == 0b100000000000; + isDrawing = (gameObj->RenderFlags & VisibilityFlags.Nameplate) != VisibilityFlags.None; if (!isDrawing) { isDrawing = ((CharacterBase*)drawObj)->HasModelInSlotLoaded != 0; diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index f8250b9..758b9f5 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -133,7 +133,6 @@ public record PairDownloadStatusMessage(List<(string PlayerName, float Progress, public record VisibilityChange : MessageBase; public record ChatChannelsUpdated : MessageBase; public record ChatChannelMessageAdded(string ChannelKey, ChatMessageEntry Message) : MessageBase; -public record ChatChannelHistoryCleared(string ChannelKey) : MessageBase; public record GroupCollectionChangedMessage : MessageBase; public record OpenUserProfileMessage(UserData User) : MessageBase; #pragma warning restore S2094 diff --git a/LightlessSync/Services/Profiles/LightlessProfileManager.cs b/LightlessSync/Services/Profiles/LightlessProfileManager.cs index 2f854e8..fd8c19c 100644 --- a/LightlessSync/Services/Profiles/LightlessProfileManager.cs +++ b/LightlessSync/Services/Profiles/LightlessProfileManager.cs @@ -327,7 +327,12 @@ public class LightlessProfileManager : MediatorSubscriberBase if (profile == null) return null; - var userData = profile.User; + if (profile.User is null) + { + Logger.LogWarning("Lightfinder profile response missing user info for CID {HashedCid}", hashedCid); + } + + var userData = profile.User ?? new UserData(hashedCid, Alias: "Lightfinder User"); var profileTags = profile.Tags ?? _emptyTagSet; var profileData = BuildProfileData(userData, profile, profileTags); _lightlessProfiles[userData] = profileData; diff --git a/LightlessSync/UI/StandaloneProfileUi.cs b/LightlessSync/UI/StandaloneProfileUi.cs index 684caef..d1ebdbe 100644 --- a/LightlessSync/UI/StandaloneProfileUi.cs +++ b/LightlessSync/UI/StandaloneProfileUi.cs @@ -146,12 +146,19 @@ public class StandaloneProfileUi : WindowMediatorSubscriberBase if (string.IsNullOrEmpty(hashedCid)) return LightfinderDisplayName; - var (name, address) = dalamudUtilService.FindPlayerByNameHash(hashedCid); - if (string.IsNullOrEmpty(name)) - return LightfinderDisplayName; + try + { + var (name, address) = dalamudUtilService.FindPlayerByNameHash(hashedCid); + if (string.IsNullOrEmpty(name)) + return LightfinderDisplayName; - var world = dalamudUtilService.GetWorldNameFromPlayerAddress(address); - return string.IsNullOrEmpty(world) ? name : $"{name} ({world})"; + var world = dalamudUtilService.GetWorldNameFromPlayerAddress(address); + return string.IsNullOrEmpty(world) ? name : $"{name} ({world})"; + } + catch + { + return LightfinderDisplayName; + } } protected override void DrawInternal() diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 93edc5d..e7574e8 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -95,13 +95,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase .Apply(); Mediator.Subscribe(this, OnChatChannelMessageAdded); - Mediator.Subscribe(this, msg => - { - if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, msg.ChannelKey, StringComparison.Ordinal)) - { - _scrollToBottom = true; - } - }); Mediator.Subscribe(this, _ => _scrollToBottom = true); } @@ -250,6 +243,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase for (var i = 0; i < channel.Messages.Count; i++) { var message = channel.Messages[i]; + ImGui.PushID(i); + + if (message.IsSystem) + { + DrawSystemEntry(message); + ImGui.PopID(); + continue; + } + + if (message.Payload is not { } payload) + { + ImGui.PopID(); + continue; + } + var timestampText = string.Empty; if (showTimestamps) { @@ -257,24 +265,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } var color = message.FromSelf ? UIColors.Get("LightlessBlue") : ImGuiColors.DalamudWhite; - ImGui.PushID(i); ImGui.PushStyleColor(ImGuiCol.Text, color); - ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {message.Payload.Message}"); + ImGui.TextWrapped($"{timestampText}{message.DisplayName}: {payload.Message}"); ImGui.PopStyleColor(); if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) { - var contextLocalTimestamp = message.Payload.SentAtUtc.ToLocalTime(); + 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 (ImGui.MenuItem(action.Label, string.Empty, false, action.IsEnabled)) - { - action.Execute(); - } + DrawContextMenuAction(action, actionIndex++); } ImGui.EndPopup(); @@ -538,6 +543,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return; } + if (message.Payload is not { } payload) + { + CloseReportPopup(); + ImGui.EndPopup(); + return; + } + if (_reportSubmissionResult is { } pendingResult) { _reportSubmissionResult = null; @@ -563,11 +575,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.TextUnformatted(channelLabel); ImGui.TextUnformatted($"Sender: {message.DisplayName}"); - ImGui.TextUnformatted($"Sent: {message.Payload.SentAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"); + ImGui.TextUnformatted($"Sent: {payload.SentAtUtc.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)}"); ImGui.Separator(); ImGui.PushTextWrapPos(ImGui.GetWindowContentRegionMax().X); - ImGui.TextWrapped(message.Payload.Message); + ImGui.TextWrapped(payload.Message); ImGui.PopTextWrapPos(); ImGui.Separator(); @@ -633,9 +645,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void OpenReportPopup(ChatChannelSnapshot channel, ChatMessageEntry message) { + if (message.Payload is not { } payload) + { + _logger.LogDebug("Ignoring report popup request for non-message entry in {ChannelKey}", channel.Key); + return; + } + _reportTargetChannel = channel; _reportTargetMessage = message; - _logger.LogDebug("Opening report popup for channel {ChannelKey}, message {MessageId}", channel.Key, message.Payload.MessageId); + _logger.LogDebug("Opening report popup for channel {ChannelKey}, message {MessageId}", channel.Key, payload.MessageId); _reportReason = string.Empty; _reportAdditionalContext = string.Empty; _reportError = null; @@ -650,6 +668,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (_reportSubmitting) return; + if (message.Payload is not { } payload) + { + _reportError = "Unable to report this message."; + return; + } + var trimmedReason = _reportReason.Trim(); if (trimmedReason.Length == 0) { @@ -666,7 +690,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _reportSubmissionResult = null; var descriptor = channel.Descriptor; - var messageId = message.Payload.MessageId; + var messageId = payload.MessageId; if (string.IsNullOrWhiteSpace(messageId)) { _reportSubmitting = false; @@ -743,25 +767,33 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private IEnumerable GetContextMenuActions(ChatChannelSnapshot channel, ChatMessageEntry message) { - if (TryCreateCopyMessageAction(message, out var copyAction)) + if (message.IsSystem || message.Payload is not { } payload) + yield break; + + if (TryCreateCopyMessageAction(message, payload, out var copyAction)) { yield return copyAction; } - if (TryCreateViewProfileAction(channel, message, out var viewProfile)) + if (TryCreateViewProfileAction(channel, message, payload, out var viewProfile)) { yield return viewProfile; } - if (TryCreateReportMessageAction(channel, message, out var reportAction)) + if (TryCreateMuteParticipantAction(channel, message, payload, out var muteAction)) + { + yield return muteAction; + } + + if (TryCreateReportMessageAction(channel, message, payload, out var reportAction)) { yield return reportAction; } } - private static bool TryCreateCopyMessageAction(ChatMessageEntry message, out ChatMessageContextAction action) + private static bool TryCreateCopyMessageAction(ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action) { - var text = message.Payload.Message; + var text = payload.Message; if (string.IsNullOrEmpty(text)) { action = default; @@ -769,20 +801,21 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } action = new ChatMessageContextAction( + FontAwesomeIcon.Clipboard, "Copy Message", true, () => ImGui.SetClipboardText(text)); return true; } - private bool TryCreateViewProfileAction(ChatChannelSnapshot channel, ChatMessageEntry message, out ChatMessageContextAction action) + private bool TryCreateViewProfileAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action) { action = default; switch (channel.Type) { case ChatChannelType.Group: { - var user = message.Payload.Sender.User; + var user = payload.Sender.User; if (user?.UID is not { Length: > 0 }) return false; @@ -790,6 +823,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (snapshot.PairsByUid.TryGetValue(user.UID, out var pair) && pair is not null) { action = new ChatMessageContextAction( + FontAwesomeIcon.User, "View Profile", true, () => Mediator.Publish(new ProfileOpenStandaloneMessage(pair))); @@ -797,41 +831,64 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } action = new ChatMessageContextAction( + FontAwesomeIcon.User, "View Profile", true, () => RunContextAction(() => OpenStandardProfileAsync(user))); return true; } - case ChatChannelType.Zone: - if (!message.Payload.Sender.CanResolveProfile) + if (!payload.Sender.CanResolveProfile) return false; - if (string.IsNullOrEmpty(message.Payload.Sender.Token)) + var hashedCid = payload.Sender.HashedCid; + if (string.IsNullOrEmpty(hashedCid)) return false; action = new ChatMessageContextAction( + FontAwesomeIcon.User, "View Profile", true, - () => RunContextAction(() => OpenZoneParticipantProfileAsync(channel.Descriptor, message.Payload.Sender.Token))); + () => RunContextAction(() => OpenLightfinderProfileInternalAsync(hashedCid))); return true; - default: return false; } } - private bool TryCreateReportMessageAction(ChatChannelSnapshot channel, ChatMessageEntry message, out ChatMessageContextAction action) + private bool TryCreateMuteParticipantAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action) { action = default; if (message.FromSelf) return false; - if (string.IsNullOrWhiteSpace(message.Payload.MessageId)) + if (string.IsNullOrEmpty(payload.Sender.Token)) + return false; + + var safeName = string.IsNullOrWhiteSpace(message.DisplayName) + ? "Participant" + : message.DisplayName; + + action = new ChatMessageContextAction( + FontAwesomeIcon.VolumeMute, + $"Mute '{safeName}'", + true, + () => RunContextAction(() => _zoneChatService.SetParticipantMuteAsync(channel.Descriptor, payload.Sender.Token!, true))); + return true; + } + + private bool TryCreateReportMessageAction(ChatChannelSnapshot channel, ChatMessageEntry message, ChatMessageDto payload, out ChatMessageContextAction action) + { + action = default; + if (message.FromSelf) + return false; + + if (string.IsNullOrWhiteSpace(payload.MessageId)) return false; action = new ChatMessageContextAction( + FontAwesomeIcon.ExclamationTriangle, "Report Message", true, () => OpenReportPopup(channel, message)); @@ -863,24 +920,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase }); } - private async Task OpenZoneParticipantProfileAsync(ChatChannelDescriptor descriptor, string token) + private void OnChatChannelMessageAdded(ChatChannelMessageAdded message) { - var result = await _zoneChatService.ResolveParticipantAsync(descriptor, token).ConfigureAwait(false); - if (result is null) + if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) { - Mediator.Publish(new NotificationMessage("Zone Chat", "Participant is no longer available.", NotificationType.Warning, TimeSpan.FromSeconds(3))); - return; + _scrollToBottom = true; } - - var resolved = result.Value; - var hashedCid = resolved.Sender.HashedCid; - if (string.IsNullOrEmpty(hashedCid)) - { - Mediator.Publish(new NotificationMessage("Zone Chat", "This participant remains anonymous.", NotificationType.Warning, TimeSpan.FromSeconds(3))); - return; - } - - await OpenLightfinderProfileInternalAsync(hashedCid).ConfigureAwait(false); } private async Task OpenLightfinderProfileInternalAsync(string hashedCid) @@ -901,14 +946,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase Mediator.Publish(new OpenLightfinderProfileMessage(sanitizedUser, profile.Value.ProfileData, hashedCid)); } - private void OnChatChannelMessageAdded(ChatChannelMessageAdded message) - { - if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) - { - _scrollToBottom = true; - } - } - private void EnsureSelectedChannel(IReadOnlyList channels) { if (_selectedChannelKey is not null && channels.Any(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal))) @@ -1374,5 +1411,86 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f); } - private readonly record struct ChatMessageContextAction(string Label, bool IsEnabled, Action Execute); + private void DrawSystemEntry(ChatMessageEntry entry) + { + var system = entry.SystemMessage; + if (system is null) + return; + + switch (system.Type) + { + case ChatSystemEntryType.ZoneSeparator: + DrawZoneSeparatorEntry(system, entry.ReceivedAtUtc); + break; + } + } + + private void DrawZoneSeparatorEntry(ChatSystemEntry systemEntry, DateTime timestampUtc) + { + ImGui.Spacing(); + + var zoneName = string.IsNullOrWhiteSpace(systemEntry.ZoneName) ? "Zone" : systemEntry.ZoneName; + var localTime = timestampUtc.ToLocalTime(); + var label = $"{localTime.ToString("HH:mm", CultureInfo.CurrentCulture)} - {zoneName}"; + var availableWidth = ImGui.GetContentRegionAvail().X; + var textSize = ImGui.CalcTextSize(label); + var cursor = ImGui.GetCursorPos(); + var textPosX = cursor.X + MathF.Max(0f, (availableWidth - textSize.X) * 0.5f); + + ImGui.SetCursorPos(new Vector2(textPosX, cursor.Y)); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey2); + ImGui.TextUnformatted(label); + ImGui.PopStyleColor(); + + var nextY = ImGui.GetCursorPosY() + ImGui.GetStyle().ItemSpacing.Y * 0.35f; + ImGui.SetCursorPos(new Vector2(cursor.X, nextY)); + ImGui.Separator(); + ImGui.Spacing(); + } + + private void DrawContextMenuAction(ChatMessageContextAction action, int index) + { + ImGui.PushID(index); + using var disabled = ImRaii.Disabled(!action.IsEnabled); + + var availableWidth = Math.Max(1f, ImGui.GetContentRegionAvail().X); + var clicked = ImGui.Selectable("##chat_ctx_action", false, ImGuiSelectableFlags.None, new Vector2(availableWidth, 0f)); + + var drawList = ImGui.GetWindowDrawList(); + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + var itemHeight = itemMax.Y - itemMin.Y; + var style = ImGui.GetStyle(); + var textColor = ImGui.GetColorU32(action.IsEnabled ? ImGuiCol.Text : ImGuiCol.TextDisabled); + + var textSize = ImGui.CalcTextSize(action.Label); + var textPos = new Vector2(itemMin.X + style.FramePadding.X, itemMin.Y + (itemHeight - textSize.Y) * 0.5f); + + if (action.Icon.HasValue) + { + var iconSize = _uiSharedService.GetIconSize(action.Icon.Value); + var iconPos = new Vector2( + itemMin.X + style.FramePadding.X, + itemMin.Y + (itemHeight - iconSize.Y) * 0.5f); + + using (_uiSharedService.IconFont.Push()) + { + drawList.AddText(iconPos, textColor, action.Icon.Value.ToIconString()); + } + + textPos.X = iconPos.X + iconSize.X + style.ItemSpacing.X; + } + + drawList.AddText(textPos, textColor, action.Label); + + if (clicked && action.IsEnabled) + { + ImGui.CloseCurrentPopup(); + action.Execute(); + } + + ImGui.PopID(); + } + + private readonly record struct ChatMessageContextAction(FontAwesomeIcon? Icon, string Label, bool IsEnabled, Action Execute); } diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index 0a39219..24448c7 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -60,10 +60,10 @@ public partial class ApiController await _lightlessHub.InvokeAsync(nameof(ReportChatMessage), request).ConfigureAwait(false); } - public async Task ResolveChatParticipant(ChatParticipantResolveRequestDto request) + public async Task SetChatParticipantMute(ChatParticipantMuteRequestDto request) { - if (!IsConnected || _lightlessHub is null) return null; - return await _lightlessHub.InvokeAsync(nameof(ResolveChatParticipant), request).ConfigureAwait(false); + if (!IsConnected || _lightlessHub is null) return; + await _lightlessHub.InvokeAsync(nameof(SetChatParticipantMute), request).ConfigureAwait(false); } public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null) diff --git a/ffxiv_pictomancy b/ffxiv_pictomancy index 788bc33..66c9667 160000 --- a/ffxiv_pictomancy +++ b/ffxiv_pictomancy @@ -1 +1 @@ -Subproject commit 788bc339a67e7a3db01a47a954034a83b9c3b61b +Subproject commit 66c96678a29454f178c681d8920a8ee0a9d50c40 -- 2.49.1 From 6ca491ac30f048eb27390c832365111979d57c9d Mon Sep 17 00:00:00 2001 From: Minmoose Date: Thu, 18 Dec 2025 16:03:35 -0600 Subject: [PATCH 113/140] Update for Brio API v3 --- LightlessSync/Interop/Ipc/IpcCallerBrio.cs | 70 ++++++++++++---------- LightlessSync/LightlessSync.csproj | 1 + LightlessSync/packages.lock.json | 24 ++------ 3 files changed, 44 insertions(+), 51 deletions(-) diff --git a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs index 83105d9..0ddaed8 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs @@ -1,6 +1,6 @@ -using Dalamud.Game.ClientState.Objects.Types; +using Brio.API; +using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Plugin; -using Dalamud.Plugin.Ipc; using LightlessSync.API.Dto.CharaData; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; @@ -13,21 +13,23 @@ namespace LightlessSync.Interop.Ipc; public sealed class IpcCallerBrio : IpcServiceBase { - private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(0, 0, 0, 0)); + private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(3, 0, 0, 0)); private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtilService; - private readonly ICallGateSubscriber<(int, int)> _brioApiVersion; - private readonly ICallGateSubscriber> _brioSpawnActorAsync; - private readonly ICallGateSubscriber _brioDespawnActor; - private readonly ICallGateSubscriber _brioSetModelTransform; - private readonly ICallGateSubscriber _brioGetModelTransform; - private readonly ICallGateSubscriber _brioGetPoseAsJson; - private readonly ICallGateSubscriber _brioSetPoseFromJson; - private readonly ICallGateSubscriber _brioFreezeActor; - private readonly ICallGateSubscriber _brioFreezePhysics; + private readonly ApiVersion _apiVersion; + private readonly SpawnActor _spawnActor; + private readonly DespawnActor _despawnActor; + private readonly SetModelTransform _setModelTransform; + private readonly GetModelTransform _getModelTransform; + + private readonly GetPoseAsJson _getPoseAsJson; + private readonly LoadPoseFromJson _setPoseFromJson; + + private readonly FreezeActor _freezeActor; + private readonly FreezePhysics _freezePhysics; public IpcCallerBrio(ILogger logger, IDalamudPluginInterface dalamudPluginInterface, DalamudUtilService dalamudUtilService, LightlessMediator mediator) : base(logger, mediator, dalamudPluginInterface, BrioDescriptor) @@ -35,15 +37,18 @@ public sealed class IpcCallerBrio : IpcServiceBase _logger = logger; _dalamudUtilService = dalamudUtilService; - _brioApiVersion = dalamudPluginInterface.GetIpcSubscriber<(int, int)>("Brio.ApiVersion"); - _brioSpawnActorAsync = dalamudPluginInterface.GetIpcSubscriber>("Brio.Actor.SpawnExAsync"); - _brioDespawnActor = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Despawn"); - _brioSetModelTransform = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.SetModelTransform"); - _brioGetModelTransform = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.GetModelTransform"); - _brioGetPoseAsJson = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Pose.GetPoseAsJson"); - _brioSetPoseFromJson = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Pose.LoadFromJson"); - _brioFreezeActor = dalamudPluginInterface.GetIpcSubscriber("Brio.Actor.Freeze"); - _brioFreezePhysics = dalamudPluginInterface.GetIpcSubscriber("Brio.FreezePhysics"); + _apiVersion = new ApiVersion(dalamudPluginInterface); + _spawnActor = new SpawnActor(dalamudPluginInterface); + _despawnActor = new DespawnActor(dalamudPluginInterface); + + _setModelTransform = new SetModelTransform(dalamudPluginInterface); + _getModelTransform = new GetModelTransform(dalamudPluginInterface); + + _getPoseAsJson = new GetPoseAsJson(dalamudPluginInterface); + _setPoseFromJson = new LoadPoseFromJson(dalamudPluginInterface); + + _freezeActor = new FreezeActor(dalamudPluginInterface); + _freezePhysics = new FreezePhysics(dalamudPluginInterface); CheckAPI(); } @@ -52,7 +57,7 @@ public sealed class IpcCallerBrio : IpcServiceBase { if (!APIAvailable) return null; _logger.LogDebug("Spawning Brio Actor"); - return await _brioSpawnActorAsync.InvokeFunc(false, false, true).ConfigureAwait(false); + return await _dalamudUtilService.RunOnFrameworkThread(() => _spawnActor.Invoke(Brio.API.Enums.SpawnFlags.Default, true)).ConfigureAwait(false); } public async Task DespawnActorAsync(nint address) @@ -61,7 +66,7 @@ public sealed class IpcCallerBrio : IpcServiceBase var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); if (gameObject == null) return false; _logger.LogDebug("Despawning Brio Actor {actor}", gameObject.Name.TextValue); - return await _dalamudUtilService.RunOnFrameworkThread(() => _brioDespawnActor.InvokeFunc(gameObject)).ConfigureAwait(false); + return await _dalamudUtilService.RunOnFrameworkThread(() => _despawnActor.Invoke(gameObject)).ConfigureAwait(false); } public async Task ApplyTransformAsync(nint address, WorldData data) @@ -71,7 +76,7 @@ public sealed class IpcCallerBrio : IpcServiceBase if (gameObject == null) return false; _logger.LogDebug("Applying Transform to Actor {actor}", gameObject.Name.TextValue); - return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetModelTransform.InvokeFunc(gameObject, + return await _dalamudUtilService.RunOnFrameworkThread(() => _setModelTransform.Invoke(gameObject, new Vector3(data.PositionX, data.PositionY, data.PositionZ), new Quaternion(data.RotationX, data.RotationY, data.RotationZ, data.RotationW), new Vector3(data.ScaleX, data.ScaleY, data.ScaleZ), false)).ConfigureAwait(false); @@ -82,8 +87,7 @@ public sealed class IpcCallerBrio : IpcServiceBase if (!APIAvailable) return default; var gameObject = await _dalamudUtilService.CreateGameObjectAsync(address).ConfigureAwait(false); if (gameObject == null) return default; - var data = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetModelTransform.InvokeFunc(gameObject)).ConfigureAwait(false); - //_logger.LogDebug("Getting Transform from Actor {actor}", gameObject.Name.TextValue); + var data = await _dalamudUtilService.RunOnFrameworkThread(() => _getModelTransform.Invoke(gameObject)).ConfigureAwait(false); return new WorldData() { @@ -107,7 +111,7 @@ public sealed class IpcCallerBrio : IpcServiceBase if (gameObject == null) return null; _logger.LogDebug("Getting Pose from Actor {actor}", gameObject.Name.TextValue); - return await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false); + return await _dalamudUtilService.RunOnFrameworkThread(() => _getPoseAsJson.Invoke(gameObject)).ConfigureAwait(false); } public async Task SetPoseAsync(nint address, string pose) @@ -118,15 +122,15 @@ public sealed class IpcCallerBrio : IpcServiceBase _logger.LogDebug("Setting Pose to Actor {actor}", gameObject.Name.TextValue); var applicablePose = JsonNode.Parse(pose)!; - var currentPose = await _dalamudUtilService.RunOnFrameworkThread(() => _brioGetPoseAsJson.InvokeFunc(gameObject)).ConfigureAwait(false); + var currentPose = await _dalamudUtilService.RunOnFrameworkThread(() => _getPoseAsJson.Invoke(gameObject)).ConfigureAwait(false); applicablePose["ModelDifference"] = JsonNode.Parse(JsonNode.Parse(currentPose)!["ModelDifference"]!.ToJsonString()); await _dalamudUtilService.RunOnFrameworkThread(() => { - _brioFreezeActor.InvokeFunc(gameObject); - _brioFreezePhysics.InvokeFunc(); + _freezeActor.Invoke(gameObject); + _freezePhysics.Invoke(); }).ConfigureAwait(false); - return await _dalamudUtilService.RunOnFrameworkThread(() => _brioSetPoseFromJson.InvokeFunc(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false); + return await _dalamudUtilService.RunOnFrameworkThread(() => _setPoseFromJson.Invoke(gameObject, applicablePose.ToJsonString(), false)).ConfigureAwait(false); } protected override IpcConnectionState EvaluateState() @@ -139,8 +143,8 @@ public sealed class IpcCallerBrio : IpcServiceBase try { - var version = _brioApiVersion.InvokeFunc(); - return version.Item1 == 2 && version.Item2 >= 0 + var version = _apiVersion.Invoke(); + return version.Item1 == 3 && version.Item2 >= 0 ? IpcConnectionState.Available : IpcConnectionState.VersionMismatch; } diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 11b9c14..10f147f 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -28,6 +28,7 @@ + diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 830a83c..61c2acc 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -8,6 +8,12 @@ "resolved": "2.0.0", "contentHash": "v447kojeuNYSY5dvtVGG2bv1+M3vOWJXcrYWwXho/2uUpuwK6qPeu5WSMlqLm4VRJu96kysVO11La0zN3dLAuQ==" }, + "Brio.API": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "0g7BTpSj/Nwfnpkz3R2FCzDIauhUdCb5zEt9cBWB0xrDrhugvUW7/irRyB48gyHDaK4Cv13al2IGrfW7l/jBUg==" + }, "DalamudPackager": { "type": "Direct", "requested": "[14.0.0, )", @@ -139,19 +145,6 @@ "resolved": "16.3.0", "contentHash": "SgMOdxbz8X65z8hraIs6hOEdnkH6hESTAIUa7viEngHOYaH+6q5XJmwr1+yb9vJpNQ19hCQY69xbFsLtXpobQA==" }, - "BCnEncoder.Net": { - "type": "Transitive", - "resolved": "2.2.1", - "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", - "dependencies": { - "CommunityToolkit.HighPerformance": "8.4.0" - } - }, - "CommunityToolkit.HighPerformance": { - "type": "Transitive", - "resolved": "8.4.0", - "contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg==" - }, "FlatSharp.Compiler": { "type": "Transitive", "resolved": "7.9.0", @@ -468,11 +461,6 @@ "Microsoft.Extensions.Primitives": "9.0.3" } }, - "Microsoft.Extensions.ObjectPool": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "bpeCq0IYmVLACyEUMzFIOQX+zZUElG1t+nu1lSxthe7B+1oNYking7b91305+jNB6iwojp9fqTY9O+Nh7ULQxg==" - }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "9.0.3", -- 2.49.1 From ee175efe4154eca6fa1a9dd453efc7429c50cd8f Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 00:15:42 +0100 Subject: [PATCH 114/140] Fixed some warnings, bumped api --- LightlessAPI | 2 +- LightlessSync/Utils/SeStringUtils.cs | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index efc0ef0..dfb0594 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit efc0ef09f9a3bf774f5e946a3b5e473865338be2 +Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 1c71204..810cbe7 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -1,21 +1,15 @@ using Dalamud.Bindings.ImGui; using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface; using Dalamud.Interface.ImGuiSeStringRenderer; -using Dalamud.Interface.Utility; using Dalamud.Interface.Textures.TextureWraps; -using Lumina.Text; +using Dalamud.Interface.Utility; using Lumina.Text.Parse; using Lumina.Text.ReadOnly; -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; using System.Numerics; using System.Reflection; using System.Text; -using System.Threading; using DalamudSeString = Dalamud.Game.Text.SeStringHandling.SeString; using DalamudSeStringBuilder = Dalamud.Game.Text.SeStringHandling.SeStringBuilder; using LuminaSeStringBuilder = Lumina.Text.SeStringBuilder; @@ -58,7 +52,7 @@ public static class SeStringUtils Color = ImGui.GetColorU32(ImGuiCol.Text), }; - var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode()}"); + var renderId = ImGui.GetID($"SeStringMarkup##{normalizedPayload.GetHashCode(StringComparison.Ordinal)}"); var drawResult = ImGuiHelpers.CompileSeStringWrapped(normalizedPayload, drawParams, renderId); var height = drawResult.Size.Y; if (height <= 0f) @@ -382,7 +376,7 @@ public static class SeStringUtils return false; return Uri.TryCreate(value, UriKind.Absolute, out var uri) - && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); + && (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) || string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)); } public static string StripMarkup(string value) -- 2.49.1 From 116e65b2208fc5d44ca601db833fb0dc9aba82a3 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 07:22:03 +0100 Subject: [PATCH 115/140] Updated submodule. updated sdk to 14.0.1 --- LightlessAPI | 2 +- LightlessSync/LightlessSync.csproj | 12 +- .../SignalR/ApIController.Functions.Users.cs | 1 - LightlessSync/WebAPI/SignalR/ApiController.cs | 5 - LightlessSync/packages.lock.json | 387 +++++++++--------- 5 files changed, 203 insertions(+), 204 deletions(-) diff --git a/LightlessAPI b/LightlessAPI index dfb0594..35f3390 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit dfb0594a5be49994cda6d95aa0d995bd93cdfbc0 +Subproject commit 35f3390dda237aaa6b4514dcef96969c24028b7f diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 10f147f..229bf15 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -35,10 +35,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + @@ -96,5 +96,9 @@ DirectXTexC.dll + + + + diff --git a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs index 24448c7..2f317b9 100644 --- a/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs +++ b/LightlessSync/WebAPI/SignalR/ApIController.Functions.Users.cs @@ -65,7 +65,6 @@ public partial class ApiController if (!IsConnected || _lightlessHub is null) return; await _lightlessHub.InvokeAsync(nameof(SetChatParticipantMute), request).ConfigureAwait(false); } - public async Task SetBroadcastStatus(bool enabled, GroupBroadcastRequestDto? groupDto = null) { CheckConnection(); diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index f017bb1..af98de9 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -1,14 +1,9 @@ -using System; using System.Reflection; -using System.Threading; -using System.Threading.Tasks; using Dalamud.Utility; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Dto; using LightlessSync.API.Dto.Chat; -using LightlessSync.API.Dto.Group; -using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.User; using LightlessSync.API.SignalR; using LightlessSync.LightlessConfiguration; diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 61c2acc..bf2bdfe 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -16,9 +16,9 @@ }, "DalamudPackager": { "type": "Direct", - "requested": "[14.0.0, )", - "resolved": "14.0.0", - "contentHash": "9c1q/eAeAs82mkQWBOaCvbt3GIQxAIadz5b/7pCXDIy9nHPtnRc+tDXEvKR+M36Wvi7n+qBTevRupkLUQp6DFA==" + "requested": "[14.0.1, )", + "resolved": "14.0.1", + "contentHash": "y0WWyUE6dhpGdolK3iKgwys05/nZaVf4ZPtIjpLhJBZvHxkkiE23zYRo7K7uqAgoK/QvK5cqF6l3VG5AbgC6KA==" }, "DotNet.ReproducibleBuilds": { "type": "Direct", @@ -37,9 +37,9 @@ }, "Glamourer.Api": { "type": "Direct", - "requested": "[2.7.0, )", - "resolved": "2.7.0", - "contentHash": "H4yRNEhdSQ+YkZlnE7qRM67GaNieb9Xe9Vpj3rvHvcSB0eWgMF1nHqCvkBNb4L38AV4WyWTzwtXh6+Rv5GuVTw==" + "requested": "[2.8.0, )", + "resolved": "2.8.0", + "contentHash": "dCxycU+lA0qraE70ZoRvM4GQAPq/K+qL/bg6t/kxKPox5GWaiunKOTXNOG2hOvgEda5WtFy6e3c9OuIM6L3faQ==" }, "K4os.Compression.LZ4.Legacy": { "type": "Direct", @@ -58,52 +58,52 @@ }, "Microsoft.AspNetCore.SignalR.Client": { "type": "Direct", - "requested": "[9.0.3, )", - "resolved": "9.0.3", - "contentHash": "V8K94AN9ADbpP2jxwt8Y++g7t/XZ7oEV+GZizNvLnR8dpCYWeveIZ/tItO54jfZJ5jmt5YyideOc+ErZbr1IZg==", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "mGlS8W2siQaJVVId2VGQ0I+7Lj49oqFxsb/bIil7GBNeazB6fBP8Ljf5tZUNzUN9WdQU5aI85WXCW9+Fsx2dZQ==", "dependencies": { - "Microsoft.AspNetCore.Http.Connections.Client": "9.0.3", - "Microsoft.AspNetCore.SignalR.Client.Core": "9.0.3" + "Microsoft.AspNetCore.Http.Connections.Client": "10.0.1", + "Microsoft.AspNetCore.SignalR.Client.Core": "10.0.1" } }, "Microsoft.AspNetCore.SignalR.Protocols.MessagePack": { "type": "Direct", - "requested": "[9.0.3, )", - "resolved": "9.0.3", - "contentHash": "mMQ21T4NuqGrX1UzSe1WBmg6TUlOmpMgCoA9kAy/uBWBZlAA4+NFavbCULyJy6zTSUAvZkG3cGSnQN4dLJlF/w==", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "WWrnA6CosCDfMKgLkmH/65c+7XV1js4FXl8Ft/Izshn3O+r8cQkT64Om7VcVy+pa6nlSt32tY5TjV/jT/84tkQ==", "dependencies": { "MessagePack": "2.5.187", - "Microsoft.AspNetCore.SignalR.Common": "9.0.3" + "Microsoft.AspNetCore.SignalR.Common": "10.0.1" } }, "Microsoft.Extensions.Hosting": { "type": "Direct", - "requested": "[9.0.3, )", - "resolved": "9.0.3", - "contentHash": "ioFXglqFA9uCYcKHI3CLVTO3I75jWIhvVxiZBzGeSPxw7XdhDLh0QvbNFrMTbZk9qqEVQcylblcvcNXnFHYXyA==", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "0jjfjQSOFZlHhwOoIQw0WyzxtkDMYdnPY3iFrOLasxYq/5J4FDt1HWT8TktBclOVjFY1FOOkoOc99X7AhbqSIw==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Configuration.Binder": "9.0.3", - "Microsoft.Extensions.Configuration.CommandLine": "9.0.3", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "9.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.3", - "Microsoft.Extensions.Configuration.Json": "9.0.3", - "Microsoft.Extensions.Configuration.UserSecrets": "9.0.3", - "Microsoft.Extensions.DependencyInjection": "9.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Diagnostics": "9.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", - "Microsoft.Extensions.FileProviders.Physical": "9.0.3", - "Microsoft.Extensions.Hosting.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging.Configuration": "9.0.3", - "Microsoft.Extensions.Logging.Console": "9.0.3", - "Microsoft.Extensions.Logging.Debug": "9.0.3", - "Microsoft.Extensions.Logging.EventLog": "9.0.3", - "Microsoft.Extensions.Logging.EventSource": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.1", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.1", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Logging.Console": "10.0.1", + "Microsoft.Extensions.Logging.Debug": "10.0.1", + "Microsoft.Extensions.Logging.EventLog": "10.0.1", + "Microsoft.Extensions.Logging.EventSource": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "NReco.Logging.File": { @@ -181,311 +181,312 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "MWkNy/Yhv2q5ZVYPHjHN6pEE5Ya1r4opqSpnsW60bgpDOT54zZ6Kpqub4Tcat8ENsR5PZcTZ3eeSAthweUb/KA==", + "resolved": "10.0.1", + "contentHash": "/jLwhtGfKPbXK395evmQYhBObZ9sZ7pckirDBTwpSM6QSJGXbUakzviOo84OmfaKj36btwfR/uaKu1hNlssUAA==", "dependencies": { - "Microsoft.Extensions.Features": "9.0.3" + "Microsoft.Extensions.Features": "10.0.1" } }, "Microsoft.AspNetCore.Http.Connections.Client": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "bLoLX67FBeYK1KKGfXrmBki/F9EAK8EKCNkyADtfFjQkJ1Qhhw1sjBlcL8TbVnZxk+FaFsyCeBPmSHgOwNIJ/A==", + "resolved": "10.0.1", + "contentHash": "+6lrifIZCL1heJtLugtkqEa191BIfUkhyAnBORbHg1eg4Vl+ijsxAzyOZxQTZbVMSJHKQCQFTEKf6H+YpSDWjA==", "dependencies": { - "Microsoft.AspNetCore.Http.Connections.Common": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.AspNetCore.Http.Connections.Common": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "Microsoft.AspNetCore.Http.Connections.Common": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "GYDAXEmaG/q9UgixPchsLAVbBUbdgG3hd8J7Af4k4GIKLsibAhos7QY7hHicyULJvRtl03totiRi5Z+JIKEnUA==", + "resolved": "10.0.1", + "contentHash": "RzryafnXWvWncojw6vD15tVdbhe3LE7MiosmLpJ5AqcWyWLk5oBACtzpp7fU5Yqa8Zc3Pcbe3jXu5DRMCRm6Xw==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "9.0.3" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.1" } }, "Microsoft.AspNetCore.SignalR.Client.Core": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "R2N03AK5FH8KIENfJGER4SgjJFMJTBiYuLbovbRunp5R4knO+iysfbYMfEFO3kn98ElWr/747dS4AeWQOEEQsg==", + "resolved": "10.0.1", + "contentHash": "lkPaGkCtVibYBzUzO8gTGsX39L5XeZl8KArueePWMYqs6c2G58ch4fmKL0qMRqsFQ84uqd+5uOJ96dClusC+IQ==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "9.0.3", - "Microsoft.AspNetCore.SignalR.Protocols.Json": "9.0.3", - "Microsoft.Extensions.DependencyInjection": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3" + "Microsoft.AspNetCore.SignalR.Common": "10.0.1", + "Microsoft.AspNetCore.SignalR.Protocols.Json": "10.0.1", + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "/568tq8YVas1mDgeScmQdQV4ZDRjdyqDS3rAo17R5Bs4puMaNM80wQSwcvsmN5gSwH6L/XRTmD1J1uRIyKXrCg==", + "resolved": "10.0.1", + "contentHash": "BwXSW2/fksJMY17fTInCb434TznOovgkON8IP6BFI54K0kiZkRDvcFxnUx27DTFAgDphz3oed8RvzFo/yGTkbQ==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "Microsoft.AspNetCore.SignalR.Protocols.Json": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "jvOdsquqrbWMP3/Aq4s8/yVeCxBkjvxarv/2WgubKkQT8nZ46aKY3Rvj1uolp4N3TuaMGlnd6mhK/tF7jCat2Q==", + "resolved": "10.0.1", + "contentHash": "mv5FggqTA1uIOhgrp+ZixYICplvCMNvpDBvV5DfT9nLJ9luteaskbqsNoaPRLZi23ZKpg20Y9ZLhxkk2C91gMA==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "9.0.3" + "Microsoft.AspNetCore.SignalR.Common": "10.0.1" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "RIEeZxWYm77+OWLwgik7DzSVSONjqkmcbuCb1koZdGAV7BgOUWnLz80VMyHZMw3onrVwFCCMHBBdruBPuQTvkg==", + "resolved": "10.0.1", + "contentHash": "njoRekyMIK+smav8B6KL2YgIfUtlsRNuT7wvurpLW+m/hoRKVnoELk2YxnUnWRGScCd1rukLMxShwLqEOKowDg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "q5qlbm6GRUrle2ZZxy9aqS/wWoc+mRD3JeP6rcpiJTh5XcemYkplAcJKq8lU11ZfPom5lfbZZfnQvDqcUhqD5Q==", + "resolved": "10.0.1", + "contentHash": "kPlU11hql+L9RjrN2N9/0GcRcRcZrNFlLLjadasFWeBORT6pL6OE+RYRk90GGCyVGSxTK+e1/f3dsMj5zpFFiQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "ad82pYBUSQbd3WIboxsS1HzFdRuHKRa2CpYwie/o6dZAxUjt62yFwjoVdM7Iw2VO5fHV1rJwa7jJZBNZin0E7Q==", + "resolved": "10.0.1", + "contentHash": "Lp4CZIuTVXtlvkAnTq6QvMSW7+H62gX2cU2vdFxHQUxvrWTpi7LwYI3X+YAyIS0r12/p7gaosco7efIxL4yFNw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Configuration.CommandLine": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "rVwz4ml/Jve/QzzUlyTVOKXVZ37op9RK6Ize4uPmJ3S5c2ErExoy816+dslBQ06ZrFq8M9bpnV5LVBuPD1ONHQ==", + "resolved": "10.0.1", + "contentHash": "s5cxcdtIig66YT3J+7iHflMuorznK8kXuwBBPHMp4KImx5ZGE3FRa1Nj9fI/xMwFV+KzUMjqZ2MhOedPH8LiBQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "fo84UIa8aSBG3pOtzLsgkj1YkOVfYFy2YWcRTCevHHAkuVsxnYnKBrcW2pyFgqqfQ/rT8K1nmRXHDdQIZ8PDig==", + "resolved": "10.0.1", + "contentHash": "csD8Eps3HQ3yc0x6NhgTV+aIFKSs3qVlVCtFnMHz/JOjnv7eEj/qSXKXiK9LzBzB1qSfAVqFnB5iaX2nUmagIQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Configuration.FileExtensions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "tBNMSDJ2q7WQK2zwPhHY5I/q95t7sf6dT079mGrNm0yOZF/gM9JvR/LtCb/rwhRmh7A6XMnzv5WbpCh9KLq9EQ==", + "resolved": "10.0.1", + "contentHash": "N/6GiwiZFCBFZDk3vg1PhHW3zMqqu5WWpmeZAA9VTXv7Q8pr8NZR/EQsH0DjzqydDksJtY6EQBsu81d5okQOlA==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", - "Microsoft.Extensions.FileProviders.Physical": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Configuration.Json": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "mjkp3ZwynNacZk4uq93I0DyCY48FZmi3yRV0xlfeDuWh44KcDunPXHwt8IWr4kL7cVM6eiFVe6YTJg97KzUAUA==", + "resolved": "10.0.1", + "contentHash": "0zW3eYBJlRctmgqk5s0kFIi5o5y2g80mvGCD8bkYxREPQlKUnr0ndU/Sop+UDIhyWN0fIi4RW63vo7BKTi7ncA==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Configuration.FileExtensions": "9.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Configuration.UserSecrets": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "vwkBQ5jqmfX7nD7CFvB3k1uSeNBKRcYRDvlk3pxJzJfm/cgT4R+hQg5AFXW/1aLKjz0q7brpRocHC5GK2sjvEw==", + "resolved": "10.0.1", + "contentHash": "ULEJ0nkaW90JYJGkFujPcJtADXcJpXiSOLbokPcWJZ8iDbtDINifEYAUVqZVr81IDNTrRFul6O8RolOKOsgFPg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Configuration.Json": "9.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", - "Microsoft.Extensions.FileProviders.Physical": "9.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Json": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Physical": "10.0.1" } }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "lDbxJpkl6X8KZGpkAxgrrthQ42YeiR0xjPp7KPx+sCPc3ZbpaIbjzd0QQ+9kDdK2RU2DOl3pc6tQyAgEZY3V0A==", + "resolved": "10.0.1", + "contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "TfaHPSe39NyL2wxkisRxXK7xvHGZYBZ+dy3r+mqGvnxKgAPdHkMu3QMQZI4pquP6W5FIQBqs8FJpWV8ffCgDqQ==" + "resolved": "10.0.1", + "contentHash": "oIy8fQxxbUsSrrOvgBqlVgOeCtDmrcynnTG+FQufcUWBrwyPfwlUkCDB2vaiBeYPyT+20u9/HeuHeBf+H4F/8g==" }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "gqhbIq6adm0+/9IlDYmchekoxNkmUTm7rfTG3k4zzoQkjRuD8TQGwL1WnIcTDt4aQ+j+Vu0OQrjI8GlpJQQhIA==", + "resolved": "10.0.1", + "contentHash": "YaocqxscJLxLit0F5yq2XyB+9C7rSRfeTL7MJIl7XwaOoUO3i0EqfO2kmtjiRduYWw7yjcSINEApYZbzjau2gQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "/fn0Xe8t+3YbMfwyTk4hFirWyAG1pBA5ogVYsrKAuuD2gbqOWhFuSA28auCmS3z8Y2eq3miDIKq4pFVRWA+J6g==", + "resolved": "10.0.1", + "contentHash": "QMoMrkNpnQym5mpfdxfxpRDuqLpsOuztguFvzH9p+Ex+do+uLFoi7UkAsBO4e9/tNR3eMFraFf2fOAi2cp3jjA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "jZuO3APLh0ePwtT9PDxiMdPwpDdct/kuExlXLCZZ+XFje/Xt815MM827EFJuxTBAbL148ywyfJyjIZ92osP5WA==" + "resolved": "10.0.1", + "contentHash": "kxUFH96eZsr63CTKGDaUUaXks7JxUxt4xs91lXeqBQmtyIEjDll2detJlBDuZTZIdmJOFoSH+YmnGr/mImcvXA==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "umczZ3+QPpzlrW/lkvy+IB0p52+qZ5w++aqx2lTCMOaPKzwcbVdrJgiQ3ajw5QWBp7gChLUiCYkSlWUpfjv24g==", + "resolved": "10.0.1", + "contentHash": "+b3DligYSZuoWltU5YdbMpIEUHNZPgPrzWfNiIuDkMdqOl93UxYB5KzS3lgpRfTXJhTNpo/CZ8w/sTkDTPDdxQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.FileProviders.Physical": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "th2+tQBV5oWjgKhip9GjiIv2AEK3QvfAO3tZcqV3F3dEt5D6Gb411RntCj1+8GS9HaRRSxjSGx/fCrMqIjkb1Q==", + "resolved": "10.0.1", + "contentHash": "4bxzGXIzZnz0Bf7czQ72jGvpOqJsRW/44PS7YLFXTTnu6cNcPvmSREDvBoH0ZVP2hAbMfL4sUoCUn54k70jPWw==", "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", - "Microsoft.Extensions.FileSystemGlobbing": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.FileSystemGlobbing": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "Rec77KHk4iNpFznHi5/6wF3MlUDcKqg26t8gRYbUm1PSukZ4B6mrXpZsJSNOiwyhhQVkjYbaoZxi5XJgRQ5lFg==" + "resolved": "10.0.1", + "contentHash": "49dFvGJjLSwGn76eHnP1gBvCJkL8HRYpCrG0DCvsP6wRpEQRLN2Fq8rTxbP+6jS7jmYKCnSVO5C65v4mT3rzeA==" }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "rHabYVhQsGYNfgnfnYLqZRx/hLe85i6jW5rnDjA9pjt3x7yjPv8T/EXcgN5T9T38FAVwZRA+RMGUkEHbxvCOBQ==", + "resolved": "10.0.1", + "contentHash": "qmoQkVZcbm4/gFpted3W3Y+1kTATZTcUhV3mRkbtpfBXlxWCHwh/2oMffVcCruaGOfJuEnyAsGyaSUouSdECOw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Diagnostics.Abstractions": "9.0.3", - "Microsoft.Extensions.FileProviders.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.1", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "utIi2R1nm+PCWkvWBf1Ou6LWqg9iLfHU23r8yyU9VCvda4dEs7xbTZSwGa5KuwbpzpgCbHCIuKaFHB3zyFmnGw==", + "resolved": "10.0.1", + "contentHash": "9ItMpMLFZFJFqCuHLLbR3LiA4ahA8dMtYuXpXl2YamSDWZhYS9BruPprkftY0tYi2bQ0slNrixdFm+4kpz1g5w==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.Extensions.DependencyInjection": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "H/MBMLt9A/69Ux4OrV7oCKt3DcMT04o5SCqDolulzQA66TLFEpYYb4qedMs/uwrLtyHXGuDGWKZse/oa8W9AZw==", + "resolved": "10.0.1", + "contentHash": "YkmyiPIWAXVb+lPIrM0LE5bbtLOJkCiRTFiHpkVOvhI7uTvCfoOHLEN0LcsY56GpSD7NqX3gJNpsaDe87/B3zg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "eVZsaKNyK0g0C1qp0mmn4Q2PiX+bXdkz8+zVkXyVMk8IvoWfmTjLjEq1MQlwt1A22lToANPiUrxPJ7Tt3V5puw==", + "resolved": "10.0.1", + "contentHash": "Zg8LLnfZs5o2RCHD/+9NfDtJ40swauemwCa7sI8gQoAye/UJHRZNpCtC7a5XE7l9Z7mdI8iMWnLZ6m7Q6S3jLg==", "dependencies": { - "Microsoft.Extensions.Configuration": "9.0.3", - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Configuration.Binder": "9.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3", - "Microsoft.Extensions.Options.ConfigurationExtensions": "9.0.3" + "Microsoft.Extensions.Configuration": "10.0.1", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.1" } }, "Microsoft.Extensions.Logging.Console": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "o9VXLOdpTAro1q7ZThIB3S8OHrRn5pr8cFUCiN85fiwlfAt2DhU4ZIfHy+jCNbf7y7S5Exbr3dlDE8mKNrs0Yg==", + "resolved": "10.0.1", + "contentHash": "38Q8sEHwQ/+wVO/mwQBa0fcdHbezFpusHE+vBw/dSr6Fq/kzZm3H/NQX511Jki/R3FHd64IY559gdlHZQtYeEA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging.Configuration": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging.Configuration": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1" } }, "Microsoft.Extensions.Logging.Debug": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "BlKgvNYjD6mY5GXpMCf9zPAsrovMgW5mzCOT7SpoOSyI1478zldf+7PKvDIscC277z5zjSO3yi/OuIWpnTZmdA==", + "resolved": "10.0.1", + "contentHash": "VqfTvbX9C6BA0VeIlpzPlljnNsXxiI5CdUHb9ksWERH94WQ6ft3oLGUAa4xKcDGu4xF+rIZ8wj7IOAd6/q7vGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1" } }, "Microsoft.Extensions.Logging.EventLog": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "/+elZUHGgB3oHKO9St/Ql/qfze9O+UbXj+9FOj1gIshLCFXcPlhpKoI11jE6eIV0kbs1P/EeffJl4KDFyvAiJQ==", + "resolved": "10.0.1", + "contentHash": "Zp9MM+jFCa7oktIug62V9eNygpkf+6kFVatF+UC/ODeUwIr5givYKy8fYSSI9sWdxqDqv63y1x0mm2VjOl8GOw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3", - "System.Diagnostics.EventLog": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "System.Diagnostics.EventLog": "10.0.1" } }, "Microsoft.Extensions.Logging.EventSource": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "hgG0EGEHnngQFQNqJ5ungEykqaQ5Tik0Gpkb38pea2a5cR3pWlZR4vuYLDdtTgSiKEKByXz/3wNQ7qAqXamEEA==", + "resolved": "10.0.1", + "contentHash": "WnFvZP+Y+lfeNFKPK/+mBpaCC7EeBDlobrQOqnP7rrw/+vE7yu8Rjczum1xbC0F/8cAHafog84DMp9200akMNQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Logging": "9.0.3", - "Microsoft.Extensions.Logging.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Logging": "10.0.1", + "Microsoft.Extensions.Logging.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "xE7MpY70lkw1oiid5y6FbL9dVw8oLfkx8RhSNGN8sSzBlCqGn0SyT3Fqc8tZnDaPIq7Z8R9RTKlS564DS+MV3g==", + "resolved": "10.0.1", + "contentHash": "G6VVwywpJI4XIobetGHwg7wDOYC2L2XBYdtskxLaKF/Ynb5QBwLl7Q//wxAR2aVCLkMpoQrjSP9VoORkyddsNQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "PcyYHQglKnWVZHSPaL6v2qnfsIuFw8tSq7cyXHg3OeuDVn/CqmdWUjRiZomCF/Gi+qCi+ksz0lFphg2cNvB8zQ==", + "resolved": "10.0.1", + "contentHash": "pL78/Im7O3WmxHzlKUsWTYchKL881udU7E26gCD3T0+/tPhWVfjPwMzfN/MRKU7aoFYcOiqcG2k1QTlH5woWow==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "9.0.3", - "Microsoft.Extensions.Configuration.Binder": "9.0.3", - "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.3", - "Microsoft.Extensions.Options": "9.0.3", - "Microsoft.Extensions.Primitives": "9.0.3" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.1", + "Microsoft.Extensions.Configuration.Binder": "10.0.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1", + "Microsoft.Extensions.Options": "10.0.1", + "Microsoft.Extensions.Primitives": "10.0.1" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "yCCJHvBcRyqapMSNzP+kTc57Eaavq2cr5Tmuil6/XVnipQf5xmskxakSQ1enU6S4+fNg3sJ27WcInV64q24JsA==" + "resolved": "10.0.1", + "contentHash": "DO8XrJkp5x4PddDuc/CH37yDBCs9BYN6ijlKyR3vMb55BP1Vwh90vOX8bNfnKxr5B2qEI3D8bvbY1fFbDveDHQ==" }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", @@ -592,8 +593,8 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "9.0.3", - "contentHash": "0nDJBZ06DVdTG2vvCZ4XjazLVaFawdT0pnji23ISX8I8fEOlRJyzH2I0kWiAbCtFwry2Zir4qE4l/GStLATfFw==" + "resolved": "10.0.1", + "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" }, "lightlesssync.api": { "type": "Project", -- 2.49.1 From ed099f322ddb4486f57c9bb235c91e5507a395d0 Mon Sep 17 00:00:00 2001 From: defnotken Date: Fri, 19 Dec 2025 08:12:18 -0600 Subject: [PATCH 116/140] Update refs and workflow --- .gitea/workflows/lightless-tag-and-release.yml | 10 +++++----- OtterGui | 2 +- Penumbra.Api | 2 +- Penumbra.GameData | 2 +- Penumbra.String | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/lightless-tag-and-release.yml b/.gitea/workflows/lightless-tag-and-release.yml index d5b7266..754bcbc 100644 --- a/.gitea/workflows/lightless-tag-and-release.yml +++ b/.gitea/workflows/lightless-tag-and-release.yml @@ -6,7 +6,7 @@ on: env: PLUGIN_NAME: LightlessSync - DOTNET_VERSION: 9.x + DOTNET_VERSION: 10.x.x jobs: tag-and-release: @@ -16,15 +16,15 @@ jobs: steps: - name: Checkout Lightless - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: true - - name: Setup .NET 9 SDK - uses: actions/setup-dotnet@v4 + - name: Setup .NET 10 SDK + uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.x + dotnet-version: 10.x.x - name: Download Dalamud run: | diff --git a/OtterGui b/OtterGui index 6f32364..ff1e654 160000 --- a/OtterGui +++ b/OtterGui @@ -1 +1 @@ -Subproject commit 6f3236453b1edfaa25c8edcc8b39a9d9b2fc18ac +Subproject commit ff1e6543845e3b8c53a5f8b240bc38faffb1b3bf diff --git a/Penumbra.Api b/Penumbra.Api index e4934cc..1750c41 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit e4934ccca0379f22dadf989ab2d34f30b3c5c7ea +Subproject commit 1750c41b53e1000c99a7fb9d8a0f082aef639a41 diff --git a/Penumbra.GameData b/Penumbra.GameData index 2ff50e6..0e973ed 160000 --- a/Penumbra.GameData +++ b/Penumbra.GameData @@ -1 +1 @@ -Subproject commit 2ff50e68f7c951f0f8b25957a400a2e32ed9d6dc +Subproject commit 0e973ed6eace6afd31cd298f8c58f76fa8d5ef60 diff --git a/Penumbra.String b/Penumbra.String index 0315144..9bd016f 160000 --- a/Penumbra.String +++ b/Penumbra.String @@ -1 +1 @@ -Subproject commit 0315144ab5614c11911e2a4dddf436fb18c5d7e3 +Subproject commit 9bd016fbef5fb2de467dd42165267fdd93cd9592 -- 2.49.1 From ae9df103f3629ff5fc7f2dcb0c7192b1b3b78208 Mon Sep 17 00:00:00 2001 From: choco Date: Fri, 19 Dec 2025 15:55:20 +0100 Subject: [PATCH 117/140] updated bullet points to wrap to the next line when it reaches the edge of the content area. --- LightlessSync/UI/UpdateNotesUi.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/LightlessSync/UI/UpdateNotesUi.cs b/LightlessSync/UI/UpdateNotesUi.cs index 340253f..e1b3ab3 100644 --- a/LightlessSync/UI/UpdateNotesUi.cs +++ b/LightlessSync/UI/UpdateNotesUi.cs @@ -195,13 +195,15 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase foreach (var item in category.Items) { + ImGui.Bullet(); + ImGui.SameLine(); if (!string.IsNullOrEmpty(item.Role)) { - ImGui.BulletText($"{item.Name} — {item.Role}"); + ImGui.TextWrapped($"{item.Name} — {item.Role}"); } else { - ImGui.BulletText(item.Name); + ImGui.TextWrapped(item.Name); } } @@ -254,7 +256,7 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase ImGui.SetScrollHereY(0); } - ImGui.PushTextWrapPos(); + ImGui.PushTextWrapPos(ImGui.GetCursorPosX() + ImGui.GetContentRegionAvail().X); foreach (var entry in _changelog.Changelog) { @@ -314,7 +316,9 @@ public class UpdateNotesUi : WindowMediatorSubscriberBase foreach (var item in version.Items) { - ImGui.BulletText(item); + ImGui.Bullet(); + ImGui.SameLine(); + ImGui.TextWrapped(item); } ImGuiHelpers.ScaledDummy(5); -- 2.49.1 From 56143c5f3dde0dece83353bb43c7b20ca97ae11d Mon Sep 17 00:00:00 2001 From: defnotken Date: Fri, 19 Dec 2025 09:32:01 -0600 Subject: [PATCH 118/140] bump api + sdk --- LightlessAPI | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessAPI b/LightlessAPI index 35f3390..8e4432a 160000 --- a/LightlessAPI +++ b/LightlessAPI @@ -1 +1 @@ -Subproject commit 35f3390dda237aaa6b4514dcef96969c24028b7f +Subproject commit 8e4432af45c1955436afe309c93e019577ad10e5 -- 2.49.1 From 68dc8aef2fc5651d58060b0015bcb2d65308448d Mon Sep 17 00:00:00 2001 From: defnotken Date: Fri, 19 Dec 2025 09:42:17 -0600 Subject: [PATCH 119/140] sdk update --- LightlessSync/LightlessSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 229bf15..16c9806 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -1,5 +1,5 @@ - + -- 2.49.1 From 05770d9a5be25b8ff0ec78c054a283d1de9ea58f Mon Sep 17 00:00:00 2001 From: defnotken Date: Fri, 19 Dec 2025 10:25:29 -0600 Subject: [PATCH 120/140] update workflow --- .gitea/workflows/lightless-tag-and-release.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/lightless-tag-and-release.yml b/.gitea/workflows/lightless-tag-and-release.yml index 754bcbc..a91d953 100644 --- a/.gitea/workflows/lightless-tag-and-release.yml +++ b/.gitea/workflows/lightless-tag-and-release.yml @@ -6,7 +6,9 @@ on: env: PLUGIN_NAME: LightlessSync - DOTNET_VERSION: 10.x.x + DOTNET_VERSION: | + 10.x.x + 9.x.x jobs: tag-and-release: @@ -19,12 +21,14 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 - submodules: true + submodules: recursive - name: Setup .NET 10 SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: 10.x.x + dotnet-version: | + 10.x.x + 9.x.x - name: Download Dalamud run: | -- 2.49.1 From 234fe5d3606a9506a7abeaa51e44470769c18df9 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 19:20:41 +0100 Subject: [PATCH 121/140] Fixed font size issue on player names. --- LightlessSync/UI/Handlers/IdDisplayHandler.cs | 3 +- LightlessSync/Utils/SeStringUtils.cs | 80 ++++++++++++++++--- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/LightlessSync/UI/Handlers/IdDisplayHandler.cs b/LightlessSync/UI/Handlers/IdDisplayHandler.cs index b31b145..74a6571 100644 --- a/LightlessSync/UI/Handlers/IdDisplayHandler.cs +++ b/LightlessSync/UI/Handlers/IdDisplayHandler.cs @@ -122,6 +122,7 @@ public class IdDisplayHandler if (!string.Equals(_editEntry, pair.UserData.UID, StringComparison.Ordinal)) { + var targetFontSize = ImGui.GetFontSize(); var font = textIsUid ? UiBuilder.MonoFont : ImGui.GetFont(); var rowWidth = MathF.Max(editBoxWidth.Invoke(), 0f); float rowRightLimit = 0f; @@ -183,7 +184,7 @@ public class IdDisplayHandler } } - SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, font, pair.UserData.UID); + SeStringUtils.RenderSeStringWithHitbox(seString, rowStart, targetFontSize, font, pair.UserData.UID); nameRectMin = ImGui.GetItemRectMin(); nameRectMax = ImGui.GetItemRectMax(); diff --git a/LightlessSync/Utils/SeStringUtils.cs b/LightlessSync/Utils/SeStringUtils.cs index 810cbe7..2188d91 100644 --- a/LightlessSync/Utils/SeStringUtils.cs +++ b/LightlessSync/Utils/SeStringUtils.cs @@ -559,17 +559,11 @@ public static class SeStringUtils ImGui.Dummy(new Vector2(0f, textSize.Y)); } + public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); var usedFont = font ?? UiBuilder.MonoFont; - var drawParams = new SeStringDrawParams - { - Font = usedFont, - Color = 0xFFFFFFFF, - WrapWidth = float.MaxValue, - TargetDrawList = drawList - }; var textSize = ImGui.CalcTextSize(seString.TextValue); if (textSize.Y <= 0f) @@ -584,11 +578,17 @@ public static class SeStringUtils var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f); var drawPos = new Vector2(position.X, position.Y + verticalOffset); - ImGui.SetCursorScreenPos(drawPos); + var drawParams = new SeStringDrawParams + { + FontSize = usedFont.FontSize, + ScreenOffset = drawPos, + Font = usedFont, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + TargetDrawList = drawList + }; - drawParams.ScreenOffset = drawPos; - drawParams.Font = usedFont; - drawParams.FontSize = usedFont.FontSize; + ImGui.SetCursorScreenPos(drawPos); ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); @@ -614,6 +614,64 @@ public static class SeStringUtils return new Vector2(textSize.X, hitboxHeight); } + public static Vector2 RenderSeStringWithHitbox(DalamudSeString seString, Vector2 position, float? targetFontSize, ImFontPtr? font = null, string? id = null) + { + var drawList = ImGui.GetWindowDrawList(); + var usedFont = font ?? ImGui.GetFont(); + + ImGui.PushFont(usedFont); + Vector2 rawSize; + float usedEffectiveSize; + try + { + usedEffectiveSize = ImGui.GetFontSize(); + rawSize = ImGui.CalcTextSize(seString.TextValue); + } + finally + { + ImGui.PopFont(); + } + + var desiredSize = targetFontSize ?? usedEffectiveSize; + var scale = usedEffectiveSize > 0 ? (desiredSize / usedEffectiveSize) : 1f; + + var textSize = rawSize * scale; + + var style = ImGui.GetStyle(); + var frameHeight = desiredSize + style.FramePadding.Y * 2f; + var hitboxHeight = MathF.Max(frameHeight, textSize.Y); + var verticalOffset = MathF.Max((hitboxHeight - textSize.Y) * 0.5f, 0f); + + var drawPos = new Vector2(position.X, position.Y + verticalOffset); + + var drawParams = new SeStringDrawParams + { + TargetDrawList = drawList, + ScreenOffset = drawPos, + Font = usedFont, + FontSize = desiredSize, + Color = 0xFFFFFFFF, + WrapWidth = float.MaxValue, + }; + + ImGui.SetCursorScreenPos(drawPos); + ImGuiHelpers.SeStringWrapped(seString.Encode(), drawParams); + + ImGui.SetCursorScreenPos(position); + ImGui.PushID(id ?? Interlocked.Increment(ref _seStringHitboxCounter).ToString()); + + try + { + ImGui.InvisibleButton("##hitbox", new Vector2(textSize.X, hitboxHeight)); + } + finally + { + ImGui.PopID(); + } + + return new Vector2(textSize.X, hitboxHeight); + } + public static Vector2 RenderIconWithHitbox(int iconId, Vector2 position, ImFontPtr? font = null, string? id = null) { var drawList = ImGui.GetWindowDrawList(); -- 2.49.1 From 54b50886c07cda1229fb158639c14394b9e6d8ec Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 19:38:43 +0100 Subject: [PATCH 122/140] Fixed UID scaling on fontsize --- LightlessSync/UI/CompactUI.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index 2a962a6..cd758f5 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -629,8 +629,9 @@ public class CompactUi : WindowMediatorSubscriberBase { var seString = SeStringUtils.BuildFormattedPlayerName(uidText, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); - var fontPtr = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-header"); + var targetFontSize = ImGui.GetFontSize(); + var font = ImGui.GetFont(); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize ,font , "uid-header"); } else { @@ -716,8 +717,9 @@ public class CompactUi : WindowMediatorSubscriberBase { var seString = SeStringUtils.BuildFormattedPlayerName(_apiController.UID, vanityTextColor, vanityGlowColor); var cursorPos = ImGui.GetCursorScreenPos(); - var fontPtr = ImGui.GetFont(); - SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, fontPtr, "uid-footer"); + var targetFontSize = ImGui.GetFontSize(); + var font = ImGui.GetFont(); + SeStringUtils.RenderSeStringWithHitbox(seString, cursorPos, targetFontSize, font, "uid-footer"); } else { -- 2.49.1 From 20008f904d6bb821c7af79f9bf80677d4204e01a Mon Sep 17 00:00:00 2001 From: azyges Date: Sat, 20 Dec 2025 03:39:28 +0900 Subject: [PATCH 123/140] fix send button and improve input focus --- LightlessSync/UI/ZoneChatUi.cs | 92 +++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index e7574e8..6944759 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -44,6 +44,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private float _currentWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; private bool _showRulesOverlay; + private bool _refocusChatInput; + private string? _refocusChatInputKey; private string? _selectedChannelKey; private bool _scrollToBottom = true; @@ -308,46 +310,60 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _draftMessages.TryGetValue(channel.Key, out var draft); draft ??= string.Empty; + var style = ImGui.GetStyle(); + var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale; + var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X; + var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f; + + ImGui.SetNextItemWidth(-reservedWidth); + var inputId = $"##chat-input-{channel.Key}"; + if (_refocusChatInput && string.Equals(_refocusChatInputKey, channel.Key, StringComparison.Ordinal)) + { + ImGui.SetKeyboardFocusHere(); + _refocusChatInput = false; + _refocusChatInputKey = null; + } + ImGui.InputText(inputId, ref draft, MaxMessageLength); + var enterPressed = ImGui.IsItemFocused() + && (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter)); + _draftMessages[channel.Key] = draft; + + ImGui.SameLine(); + ImGui.AlignTextToFramePadding(); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextUnformatted($"{draft.Length}/{MaxMessageLength}"); + ImGui.PopStyleColor(); + + ImGui.SameLine(); + var buttonScreenPos = ImGui.GetCursorScreenPos(); + var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X; + var desiredButtonX = rightEdgeScreen - sendButtonWidth; + var minButtonX = buttonScreenPos.X + style.ItemSpacing.X; + var finalButtonX = MathF.Max(minButtonX, desiredButtonX); + ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y)); + var sendColor = UIColors.Get("LightlessPurpleDefault"); + var sendHovered = UIColors.Get("LightlessPurple"); + var sendActive = UIColors.Get("LightlessPurpleActive"); + ImGui.PushStyleColor(ImGuiCol.Button, sendColor); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, sendHovered); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, sendActive); + ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6f * ImGuiHelpers.GlobalScale); + var sendClicked = false; using (ImRaii.Disabled(!canSend)) { - var style = ImGui.GetStyle(); - var sendButtonWidth = 100f * ImGuiHelpers.GlobalScale; - var counterWidth = ImGui.CalcTextSize($"{MaxMessageLength}/{MaxMessageLength}").X; - var reservedWidth = sendButtonWidth + counterWidth + style.ItemSpacing.X * 2f; - - ImGui.SetNextItemWidth(-reservedWidth); - var inputId = $"##chat-input-{channel.Key}"; - var send = ImGui.InputText(inputId, ref draft, MaxMessageLength, ImGuiInputTextFlags.EnterReturnsTrue); - _draftMessages[channel.Key] = draft; - - ImGui.SameLine(); - ImGui.AlignTextToFramePadding(); - ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); - ImGui.TextUnformatted($"{draft.Length}/{MaxMessageLength}"); - ImGui.PopStyleColor(); - - ImGui.SameLine(); - var buttonScreenPos = ImGui.GetCursorScreenPos(); - var rightEdgeScreen = ImGui.GetWindowPos().X + ImGui.GetWindowContentRegionMax().X; - var desiredButtonX = rightEdgeScreen - sendButtonWidth; - var minButtonX = buttonScreenPos.X + style.ItemSpacing.X; - var finalButtonX = MathF.Max(minButtonX, desiredButtonX); - ImGui.SetCursorScreenPos(new Vector2(finalButtonX, buttonScreenPos.Y)); - var sendColor = UIColors.Get("LightlessPurpleDefault"); - var sendHovered = UIColors.Get("LightlessPurple"); - var sendActive = UIColors.Get("LightlessPurpleActive"); - ImGui.PushStyleColor(ImGuiCol.Button, sendColor); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, sendHovered); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, sendActive); - ImGui.PushStyleVar(ImGuiStyleVar.FrameRounding, 6f * ImGuiHelpers.GlobalScale); - if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, "Send", 100f * ImGuiHelpers.GlobalScale, center: true)) + if (_uiSharedService.IconTextButton(FontAwesomeIcon.PaperPlane, $"Send##chat-send-{channel.Key}", 100f * ImGuiHelpers.GlobalScale, center: true)) { - send = true; + sendClicked = true; } - ImGui.PopStyleVar(); - ImGui.PopStyleColor(3); + } + ImGui.PopStyleVar(); + ImGui.PopStyleColor(3); - if (send && TrySendDraft(channel, draft)) + if (canSend && (enterPressed || sendClicked)) + { + _refocusChatInput = true; + _refocusChatInputKey = channel.Key; + if (TrySendDraft(channel, draft)) { _draftMessages[channel.Key] = string.Empty; _scrollToBottom = true; @@ -969,6 +985,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _draftMessages.Remove(key); } } + + if (_refocusChatInputKey is not null && !existingKeys.Contains(_refocusChatInputKey)) + { + _refocusChatInputKey = null; + _refocusChatInput = false; + } } private void DrawConnectionControls() -- 2.49.1 From d2a68e6533fb34e144d135fe07bf7ae9f09de31a Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 19:49:30 +0100 Subject: [PATCH 124/140] Disabled sort on payload on group submit --- LightlessSync/UI/EditProfileUi.Group.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/UI/EditProfileUi.Group.cs b/LightlessSync/UI/EditProfileUi.Group.cs index ee6a329..7b47ced 100644 --- a/LightlessSync/UI/EditProfileUi.Group.cs +++ b/LightlessSync/UI/EditProfileUi.Group.cs @@ -332,7 +332,7 @@ public partial class EditProfileUi saveTooltip: "Apply the selected tags to this syncshell profile.", submitAction: payload => SubmitGroupTagChanges(payload), allowReorder: true, - sortPayloadBeforeSubmit: true, + sortPayloadBeforeSubmit: false, onPayloadPrepared: payload => { _tagEditorSelection.Clear(); @@ -586,7 +586,7 @@ public partial class EditProfileUi IsNsfw: null, IsDisabled: null)).ConfigureAwait(false); - _profileTagIds = payload.Length == 0 ? Array.Empty() : payload.ToArray(); + _profileTagIds = payload.Length == 0 ? [] : [.. payload]; Mediator.Publish(new ClearProfileGroupDataMessage(_groupInfo.Group)); } catch (Exception ex) -- 2.49.1 From 934cdfbcf0d0dd6954fb100a2d7c2ab076d6b45a Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 19:57:56 +0100 Subject: [PATCH 125/140] updated nuget packages --- LightlessSync/LightlessSync.csproj | 10 ++--- LightlessSync/packages.lock.json | 66 +++++++++++++++--------------- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 16c9806..85df867 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -31,7 +31,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -39,13 +39,13 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index bf2bdfe..47b77e9 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -52,9 +52,9 @@ }, "Meziantou.Analyzer": { "type": "Direct", - "requested": "[2.0.212, )", - "resolved": "2.0.212", - "contentHash": "U91ktjjTRTccUs3Lk+hrLD9vW+2+lhnsOf4G1GpRSJi1pLn3uK5CU6wGP9Bmz1KlJs6Oz1GGoMhxQBoqQsmAuQ==" + "requested": "[2.0.264, )", + "resolved": "2.0.264", + "contentHash": "zRG13RDG446rZNdd/YjKRd4utpbjleRDUqNQSrX0etMnH8Rz9NBlXUpS5aR2ExoOokhNfkdOW8HpLzjLj5x0hQ==" }, "Microsoft.AspNetCore.SignalR.Client": { "type": "Direct", @@ -108,35 +108,35 @@ }, "NReco.Logging.File": { "type": "Direct", - "requested": "[1.2.2, )", - "resolved": "1.2.2", - "contentHash": "UyUIkyDiHi2HAJlmEWqeKN9/FxTF0DPNdyatzMDMTXvUpgvqBFneJ2qDtZkXRJNG8eR6jU+KsbGeMmChgUdRUg==", + "requested": "[1.3.1, )", + "resolved": "1.3.1", + "contentHash": "4aFUEW1OFJsuKtg46dnqxZUyb37f9dzaWOXjUv2x/wzoHKovR9yqiMzXtCZt3+a9G78YCIAtSEz2g/GaNYbxSQ==", "dependencies": { - "Microsoft.Extensions.Logging": "8.0.1", - "Microsoft.Extensions.Logging.Configuration": "8.0.1", - "Microsoft.Extensions.Options.ConfigurationExtensions": "8.0.0" + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" } }, "SixLabors.ImageSharp": { "type": "Direct", - "requested": "[3.1.11, )", - "resolved": "3.1.11", - "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" }, "SonarAnalyzer.CSharp": { "type": "Direct", - "requested": "[10.7.0.110445, )", - "resolved": "10.7.0.110445", - "contentHash": "U4v2LWopxADYkUv7Z5CX7ifKMdDVqHb7a1bzppIQnQi4WQR6z1Zi5rDkCHlVYGEd1U/WMz1IJCU8OmFZLJpVig==" + "requested": "[10.17.0.131074, )", + "resolved": "10.17.0.131074", + "contentHash": "N8agHzX1pK3Xv/fqMig/mHspPAmh/aKkGg7lUC1xfezAhFtPTuRqBjuyas622Tvy5jnsN5zCXJVclvNkfJJ4rQ==" }, "System.IdentityModel.Tokens.Jwt": { "type": "Direct", - "requested": "[8.7.0, )", - "resolved": "8.7.0", - "contentHash": "8dKL3A9pVqYCJIXHd4H2epQqLxSvKeNxGonR0e5g89yMchyvsM/NLuB06otx29BicUd6+LUJZgNZmvYjjPsPGg==", + "requested": "[8.15.0, )", + "resolved": "8.15.0", + "contentHash": "dpodi7ixz6hxK8YCBYAWzm0IA8JYXoKcz0hbCbNifo519//rjUI0fBD8rfNr+IGqq+2gm4oQoXwHk09LX5SqqQ==", "dependencies": { - "Microsoft.IdentityModel.JsonWebTokens": "8.7.0", - "Microsoft.IdentityModel.Tokens": "8.7.0" + "Microsoft.IdentityModel.JsonWebTokens": "8.15.0", + "Microsoft.IdentityModel.Tokens": "8.15.0" } }, "YamlDotNet": { @@ -490,32 +490,32 @@ }, "Microsoft.IdentityModel.Abstractions": { "type": "Transitive", - "resolved": "8.7.0", - "contentHash": "OQd5aVepYvh5evOmBMeAYjMIpEcTf1ZCBZaU7Nh/RlhhdXefjFDJeP1L2F2zeNT1unFr+wUu/h3Ac2Xb4BXU6w==" + "resolved": "8.15.0", + "contentHash": "e/DApa1GfxUqHSBHcpiQg8yaghKAvFVBQFcWh25jNoRobDZbduTUACY8bZ54eeGWXvimGmEDdF0zkS5Dq16XPQ==" }, "Microsoft.IdentityModel.JsonWebTokens": { "type": "Transitive", - "resolved": "8.7.0", - "contentHash": "uzsSAWhNhbrkWbQKBTE8QhzviU6sr3bJ1Bkv7gERlhswfSKOp7HsxTRLTPBpx/whQ/GRRHEwMg8leRIPbMrOgw==", + "resolved": "8.15.0", + "contentHash": "3513f5VzvOZy3ELd42wGnh1Q3e83tlGAuXFSNbENpgWYoAhLLzgFtd5PiaOPGAU0gqKhYGVzKavghLUGfX3HQg==", "dependencies": { - "Microsoft.IdentityModel.Tokens": "8.7.0" + "Microsoft.IdentityModel.Tokens": "8.15.0" } }, "Microsoft.IdentityModel.Logging": { "type": "Transitive", - "resolved": "8.7.0", - "contentHash": "Bs0TznPAu+nxa9rAVHJ+j3CYECHJkT3tG8AyBfhFYlT5ldsDhoxFT7J+PKxJHLf+ayqWfvDZHHc4639W2FQCxA==", + "resolved": "8.15.0", + "contentHash": "1gJLjhy0LV2RQMJ9NGzi5Tnb2l+c37o8D8Lrk2mrvmb6OQHZ7XJstd/XxvncXgBpad4x9CGXdipbZzJJCXKyAg==", "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.7.0" + "Microsoft.IdentityModel.Abstractions": "8.15.0" } }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", - "resolved": "8.7.0", - "contentHash": "5Z6voXjRXAnGklhmZd1mKz89UhcF5ZQQZaZc2iKrOuL4Li1UihG2vlJx8IbiFAOIxy/xdbsAm0A+WZEaH5fxng==", + "resolved": "8.15.0", + "contentHash": "zUE9ysJXBtXlHHRtcRK3Sp8NzdCI1z/BRDTXJQ2TvBoI0ENRtnufYIep0O5TSCJRJGDwwuLTUx+l/bEYZUxpCA==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.2", - "Microsoft.IdentityModel.Logging": "8.7.0" + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.IdentityModel.Logging": "8.15.0" } }, "Microsoft.NET.StringTools": { @@ -619,7 +619,7 @@ "FlatSharp.Runtime": "[7.9.0, )", "OtterGui": "[1.0.0, )", "Penumbra.Api": "[5.13.0, )", - "Penumbra.String": "[1.0.6, )" + "Penumbra.String": "[1.0.7, )" } }, "penumbra.string": { -- 2.49.1 From 4d0bf2d57e5d2ebe21868768f4ddb48252336f53 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 22:29:37 +0100 Subject: [PATCH 126/140] Updated Brio SDK --- LightlessSync/LightlessSync.csproj | 2 +- LightlessSync/packages.lock.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 85df867..28e7eb2 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -28,7 +28,7 @@ - + diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 47b77e9..d47880c 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -10,9 +10,9 @@ }, "Brio.API": { "type": "Direct", - "requested": "[3.0.0, )", - "resolved": "3.0.0", - "contentHash": "0g7BTpSj/Nwfnpkz3R2FCzDIauhUdCb5zEt9cBWB0xrDrhugvUW7/irRyB48gyHDaK4Cv13al2IGrfW7l/jBUg==" + "requested": "[3.0.1, )", + "resolved": "3.0.1", + "contentHash": "40MD49ETqyGsdHGoG3JF/BFcNAphRqi27+ZxfDk2Aj7gAkzDFe7C2UVGirUByrUIj8lxiz9eEoB2i7O9lefEPQ==" }, "DalamudPackager": { "type": "Direct", -- 2.49.1 From ac8270e4ad708406b16805e7980278b4f0738636 Mon Sep 17 00:00:00 2001 From: cake Date: Fri, 19 Dec 2025 22:34:04 +0100 Subject: [PATCH 127/140] Added chat command in handler --- LightlessSync/Services/CommandManagerService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index 51c57f9..d42a865 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -48,7 +48,8 @@ public sealed class CommandManagerService : IDisposable "\t /light gpose - Opens the Lightless Character Data Hub window" + Environment.NewLine + "\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine + "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine + - "\t /light finder - Opens the Lightfinder window" + "\t /light finder - Opens the Lightfinder window" + Environment.NewLine + + "\t /light finder - Opens the Lightless Chat window" }); } @@ -133,5 +134,9 @@ public sealed class CommandManagerService : IDisposable { _mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); } + else if (string.Equals(splitArgs[0], "chat", StringComparison.OrdinalIgnoreCase)) + { + _mediator.Publish(new UiToggleMessage(typeof(ZoneChatUi))); + } } } \ No newline at end of file -- 2.49.1 From e5fa477eee735c3bfe3a78d0a645a9edad73b9ea Mon Sep 17 00:00:00 2001 From: Minmoose Date: Fri, 19 Dec 2025 19:06:34 -0600 Subject: [PATCH 128/140] Fix Brio IPC --- LightlessSync/Interop/Ipc/IpcCallerBrio.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs index 0ddaed8..98836a4 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerBrio.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerBrio.cs @@ -13,7 +13,7 @@ namespace LightlessSync.Interop.Ipc; public sealed class IpcCallerBrio : IpcServiceBase { - private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(3, 0, 0, 0)); + private static readonly IpcServiceDescriptor BrioDescriptor = new("Brio", "Brio", new Version(0, 0, 0, 0)); private readonly ILogger _logger; private readonly DalamudUtilService _dalamudUtilService; @@ -144,7 +144,7 @@ public sealed class IpcCallerBrio : IpcServiceBase try { var version = _apiVersion.Invoke(); - return version.Item1 == 3 && version.Item2 >= 0 + return version.Breaking == 3 && version.Feature >= 0 ? IpcConnectionState.Available : IpcConnectionState.VersionMismatch; } -- 2.49.1 From ab369d008e30363f66b172bb692fa62d9cc5f8f4 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 01:17:00 +0900 Subject: [PATCH 129/140] can drag chat tabs around as much as u want syncshell tabs can use notes instead by rightclicking and prefering it added some visibility settings (hide in combat, etc) and cleaned up some of the ui --- .../Configurations/ChatConfig.cs | 8 + LightlessSync/Plugin.cs | 2 + .../Services/Chat/ZoneChatService.cs | 116 +++++- LightlessSync/Services/DalamudUtilService.cs | 27 ++ LightlessSync/UI/DataAnalysisUi.cs | 4 +- LightlessSync/UI/ZoneChatUi.cs | 353 +++++++++++++++++- LightlessSync/WebAPI/SignalR/ApiController.cs | 5 +- 7 files changed, 493 insertions(+), 22 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 9065b81..dcdfc78 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace LightlessSync.LightlessConfiguration.Configurations; @@ -13,4 +14,11 @@ public sealed class ChatConfig : ILightlessConfiguration public bool IsWindowPinned { get; set; } = false; public bool AutoOpenChatOnPluginLoad { get; set; } = false; public float ChatFontScale { get; set; } = 1.0f; + public bool HideInCombat { get; set; } = false; + public bool HideInDuty { get; set; } = false; + public bool ShowWhenUiHidden { get; set; } = true; + public bool ShowInCutscenes { get; set; } = true; + public bool ShowInGpose { get; set; } = true; + public List ChannelOrder { get; set; } = new(); + public Dictionary PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal); } diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index d8e5ee7..58374e3 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -1,5 +1,6 @@ using Dalamud.Game; using Dalamud.Game.ClientState.Objects; +using Dalamud.Interface; using Dalamud.Interface.ImGuiFileDialog; using Dalamud.Interface.Windowing; using Dalamud.Plugin; @@ -105,6 +106,7 @@ public sealed class Plugin : IDalamudPlugin services.AddSingleton(new Dalamud.Localization("LightlessSync.Localization.", string.Empty, useEmbedded: true)); services.AddSingleton(gameGui); services.AddSingleton(addonLifecycle); + services.AddSingleton(pluginInterface.UiBuilder); // Core singletons services.AddSingleton(); diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 55009ab..6eebf4f 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -23,6 +23,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS private readonly DalamudUtilService _dalamudUtilService; private readonly ActorObjectService _actorObjectService; private readonly PairUiService _pairUiService; + private readonly ChatConfigService _chatConfigService; private readonly Lock _sync = new(); @@ -57,6 +58,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS _dalamudUtilService = dalamudUtilService; _actorObjectService = actorObjectService; _pairUiService = pairUiService; + _chatConfigService = chatConfigService; _isLoggedIn = _dalamudUtilService.IsLoggedIn; _isConnected = _apiController.IsConnected; @@ -136,6 +138,42 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } } + public void MoveChannel(string draggedKey, string targetKey) + { + if (string.IsNullOrWhiteSpace(draggedKey) || string.IsNullOrWhiteSpace(targetKey)) + { + return; + } + + bool updated = false; + using (_sync.EnterScope()) + { + if (!_channels.ContainsKey(draggedKey) || !_channels.ContainsKey(targetKey)) + { + return; + } + + var fromIndex = _channelOrder.IndexOf(draggedKey); + var toIndex = _channelOrder.IndexOf(targetKey); + if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) + { + return; + } + + _channelOrder.RemoveAt(fromIndex); + var insertIndex = Math.Clamp(toIndex, 0, _channelOrder.Count); + _channelOrder.Insert(insertIndex, draggedKey); + _chatConfigService.Current.ChannelOrder = new List(_channelOrder); + _chatConfigService.Save(); + updated = true; + } + + if (updated) + { + PublishChannelListChanged(); + } + } + public Task SetChatEnabledAsync(bool enabled) => enabled ? EnableChatAsync() : DisableChatAsync(); @@ -512,7 +550,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS if (!_isLoggedIn || !_apiController.IsConnected) { - await LeaveCurrentZoneAsync(force, 0).ConfigureAwait(false); + await LeaveCurrentZoneAsync(force, 0, 0).ConfigureAwait(false); return; } @@ -520,6 +558,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { var location = await _dalamudUtilService.GetMapDataAsync().ConfigureAwait(false); var territoryId = (ushort)location.TerritoryId; + var worldId = (ushort)location.ServerId; string? zoneKey; ZoneChannelDefinition? definition = null; @@ -536,14 +575,14 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS if (definition is null) { - await LeaveCurrentZoneAsync(force, territoryId).ConfigureAwait(false); + await LeaveCurrentZoneAsync(force, territoryId, worldId).ConfigureAwait(false); return; } var descriptor = await BuildZoneDescriptorAsync(definition.Value).ConfigureAwait(false); if (descriptor is null) { - await LeaveCurrentZoneAsync(force, territoryId).ConfigureAwait(false); + await LeaveCurrentZoneAsync(force, territoryId, worldId).ConfigureAwait(false); return; } @@ -586,7 +625,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } } - private async Task LeaveCurrentZoneAsync(bool force, ushort territoryId) + private async Task LeaveCurrentZoneAsync(bool force, ushort territoryId, ushort worldId) { ChatChannelDescriptor? descriptor = null; @@ -602,7 +641,27 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.StatusText = !_chatEnabled ? "Chat services disabled" : (_isConnected ? ZoneUnavailableMessage : "Disconnected from chat server"); - state.DisplayName = "Zone Chat"; + if (territoryId != 0 + && _dalamudUtilService.TerritoryData.Value.TryGetValue(territoryId, out var territoryName) + && !string.IsNullOrWhiteSpace(territoryName)) + { + state.DisplayName = territoryName; + } + else + { + state.DisplayName = "Zone Chat"; + } + + if (worldId != 0) + { + state.Descriptor = new ChatChannelDescriptor + { + Type = ChatChannelType.Zone, + WorldId = worldId, + ZoneId = territoryId, + CustomKey = string.Empty + }; + } } if (string.Equals(_activeChannelKey, ZoneChannelKey, StringComparison.Ordinal)) @@ -1092,17 +1151,50 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS { _channelOrder.Clear(); - if (_channels.ContainsKey(ZoneChannelKey)) + var configuredOrder = _chatConfigService.Current.ChannelOrder; + if (configuredOrder.Count > 0) { - _channelOrder.Add(ZoneChannelKey); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var key in configuredOrder) + { + if (_channels.ContainsKey(key) && seen.Add(key)) + { + _channelOrder.Add(key); + } + } + + var remaining = _channels.Values + .Where(state => !seen.Contains(state.Key)) + .ToList(); + + if (remaining.Count > 0) + { + var zoneKeys = remaining + .Where(state => state.Type == ChatChannelType.Zone) + .Select(state => state.Key); + var groupKeys = remaining + .Where(state => state.Type == ChatChannelType.Group) + .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(state => state.Key); + + _channelOrder.AddRange(zoneKeys); + _channelOrder.AddRange(groupKeys); + } } + else + { + if (_channels.ContainsKey(ZoneChannelKey)) + { + _channelOrder.Add(ZoneChannelKey); + } - var groups = _channels.Values - .Where(state => state.Type == ChatChannelType.Group) - .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) - .Select(state => state.Key); + var groups = _channels.Values + .Where(state => state.Type == ChatChannelType.Group) + .OrderBy(state => state.DisplayName, StringComparer.OrdinalIgnoreCase) + .Select(state => state.Key); - _channelOrder.AddRange(groups); + _channelOrder.AddRange(groups); + } if (_activeChannelKey is null && _channelOrder.Count > 0) { diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 06d480b..c8668eb 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -239,6 +239,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public bool IsInCombat { get; private set; } = false; public bool IsPerforming { get; private set; } = false; public bool IsInInstance { get; private set; } = false; + public bool IsInDuty => _condition[ConditionFlag.BoundByDuty]; public bool HasModifiedGameFiles => _gameData.HasModifiedGameDataFiles; public uint ClassJobId => _classJobId!.Value; public Lazy> JobData { get; private set; } @@ -248,6 +249,32 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber public bool IsLodEnabled { get; private set; } public LightlessMediator Mediator { get; } + public bool IsInFieldOperation + { + get + { + if (!IsInDuty) + { + return false; + } + + var territoryId = _clientState.TerritoryType; + if (territoryId == 0) + { + return false; + } + + if (!TerritoryData.Value.TryGetValue(territoryId, out var name) || string.IsNullOrWhiteSpace(name)) + { + return false; + } + + return name.Contains("Eureka", StringComparison.OrdinalIgnoreCase) + || name.Contains("Bozja", StringComparison.OrdinalIgnoreCase) + || name.Contains("Zadnor", StringComparison.OrdinalIgnoreCase); + } + } + public IGameObject? CreateGameObject(IntPtr reference) { EnsureIsOnFramework(); diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 94c8add..2958ebc 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -27,7 +27,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase { private const float MinTextureFilterPaneWidth = 305f; private const float MaxTextureFilterPaneWidth = 405f; - private const float MinTextureDetailPaneWidth = 580f; + private const float MinTextureDetailPaneWidth = 480f; private const float MaxTextureDetailPaneWidth = 720f; private const float SelectedFilePanelLogicalHeight = 90f; private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); @@ -111,7 +111,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _hasUpdate = true; }); WindowBuilder.For(this) - .SetSizeConstraints(new Vector2(1650, 1000), new Vector2(3840, 2160)) + .SetSizeConstraints(new Vector2(1240, 680), new Vector2(3840, 2160)) .Apply(); _conversionProgress.ProgressChanged += ConversionProgress_ProgressChanged; diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 6944759..668bcb8 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -12,6 +12,7 @@ using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Chat; using LightlessSync.Services.Mediator; +using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Services; using LightlessSync.Utils; using LightlessSync.WebAPI; @@ -23,8 +24,10 @@ namespace LightlessSync.UI; public sealed class ZoneChatUi : WindowMediatorSubscriberBase { private const string ChatDisabledStatus = "Chat services disabled"; + private const string ZoneUnavailableStatus = "Zone chat is only available in major cities."; private const string SettingsPopupId = "zone_chat_settings_popup"; private const string ReportPopupId = "Report Message##zone_chat_report_popup"; + private const string ChannelDragPayloadId = "zone_chat_channel_drag"; private const float DefaultWindowOpacity = .97f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; @@ -32,6 +35,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const float MaxChatFontScale = 1.5f; private const int ReportReasonMaxLength = 500; private const int ReportContextMaxLength = 1000; + private const int MaxChannelNoteTabLength = 25; private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; @@ -39,6 +43,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly LightlessProfileManager _profileManager; private readonly ApiController _apiController; private readonly ChatConfigService _chatConfigService; + private readonly ServerConfigurationManager _serverConfigurationManager; + private readonly DalamudUtilService _dalamudUtilService; + private readonly IUiBuilder _uiBuilder; private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; private float _currentWindowOpacity = DefaultWindowOpacity; @@ -61,6 +68,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private bool _reportSubmitting; private string? _reportError; private ChatReportResult? _reportSubmissionResult; + private string? _dragChannelKey; + private string? _dragHoverKey; + private bool _HideStateActive; + private bool _HideStateWasOpen; public ZoneChatUi( ILogger logger, @@ -70,6 +81,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase PairUiService pairUiService, LightlessProfileManager profileManager, ChatConfigService chatConfigService, + ServerConfigurationManager serverConfigurationManager, + DalamudUtilService dalamudUtilService, + IUiBuilder uiBuilder, ApiController apiController, PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "Lightless Chat", performanceCollectorService) @@ -79,6 +93,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _pairUiService = pairUiService; _profileManager = profileManager; _chatConfigService = chatConfigService; + _serverConfigurationManager = serverConfigurationManager; + _dalamudUtilService = dalamudUtilService; + _uiBuilder = uiBuilder; _apiController = apiController; _isWindowPinned = _chatConfigService.Current.IsWindowPinned; _showRulesOverlay = _chatConfigService.Current.ShowRulesOverlayOnOpen; @@ -88,6 +105,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } _unpinnedWindowFlags = Flags; RefreshWindowFlags(); + ApplyUiVisibilitySettings(); Size = new Vector2(450, 420) * ImGuiHelpers.GlobalScale; SizeCondition = ImGuiCond.FirstUseEver; WindowBuilder.For(this) @@ -98,6 +116,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase Mediator.Subscribe(this, OnChatChannelMessageAdded); Mediator.Subscribe(this, _ => _scrollToBottom = true); + Mediator.Subscribe(this, _ => UpdateHideState()); + Mediator.Subscribe(this, _ => UpdateHideState()); } public override void PreDraw() @@ -108,6 +128,55 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetNextWindowBgAlpha(_currentWindowOpacity); } + private void UpdateHideState() + { + ApplyUiVisibilitySettings(); + var shouldHide = ShouldHide(); + if (shouldHide) + { + _HideStateWasOpen |= IsOpen; + if (IsOpen) + { + IsOpen = false; + } + _HideStateActive = true; + } + else if (_HideStateActive) + { + if (_HideStateWasOpen) + { + IsOpen = true; + } + _HideStateActive = false; + _HideStateWasOpen = false; + } + } + + private void ApplyUiVisibilitySettings() + { + var config = _chatConfigService.Current; + _uiBuilder.DisableAutomaticUiHide = config.ShowWhenUiHidden; + _uiBuilder.DisableCutsceneUiHide = config.ShowInCutscenes; + _uiBuilder.DisableGposeUiHide = config.ShowInGpose; + } + + private bool ShouldHide() + { + var config = _chatConfigService.Current; + + if (config.HideInCombat && _dalamudUtilService.IsInCombat) + { + return true; + } + + if (config.HideInDuty && _dalamudUtilService.IsInDuty && !_dalamudUtilService.IsInFieldOperation) + { + return true; + } + + return false; + } + protected override void DrawInternal() { var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; @@ -155,7 +224,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private static void DrawHeader(ChatChannelSnapshot channel) + private void DrawHeader(ChatChannelSnapshot channel) { var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; Vector4 color; @@ -178,11 +247,18 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (channel.Type == ChatChannelType.Zone && channel.Descriptor.WorldId != 0) { ImGui.SameLine(); - ImGui.TextUnformatted($"World #{channel.Descriptor.WorldId}"); + var worldId = channel.Descriptor.WorldId; + var worldName = _dalamudUtilService.WorldData.Value.TryGetValue(worldId, out var name) ? name : $"World #{worldId}"; + ImGui.TextUnformatted(worldName); + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip($"World ID: {worldId}"); + } } - var showInlineDisabled = string.Equals(channel.StatusText, ChatDisabledStatus, StringComparison.OrdinalIgnoreCase); - if (showInlineDisabled) + var showInlineStatus = string.Equals(channel.StatusText, ChatDisabledStatus, StringComparison.OrdinalIgnoreCase) + || string.Equals(channel.StatusText, ZoneUnavailableStatus, StringComparison.OrdinalIgnoreCase); + if (showInlineStatus) { ImGui.SameLine(); ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); @@ -324,6 +400,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _refocusChatInputKey = null; } ImGui.InputText(inputId, ref draft, MaxMessageLength); + if (ImGui.IsItemActive() || ImGui.IsItemFocused()) + { + var drawList = ImGui.GetWindowDrawList(); + var itemMin = ImGui.GetItemRectMin(); + var itemMax = ImGui.GetItemRectMax(); + var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); + var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); + drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); + } var enterPressed = ImGui.IsItemFocused() && (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter)); _draftMessages[channel.Key] = draft; @@ -480,7 +565,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.Separator(); _uiSharedService.MediumText("Syncshell Chat Rules", UIColors.Get("LightlessYellow")); - _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("Syncshell chats are self-moderated (their own set rules) by it's owner and appointed moderators. If they fail to enforce chat rules within their syncshell, the owner (and its moderators) may face punishment.")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("Syncshell chats are self-moderated (their own set rules) by it's owner and appointed moderators.")); ImGui.Dummy(new Vector2(5)); @@ -1187,6 +1272,71 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Toggles the timestamp prefix on messages."); } + ImGui.Separator(); + ImGui.TextUnformatted("Chat Visibility"); + + var autoHideCombat = chatConfig.HideInCombat; + if (ImGui.Checkbox("Hide in combat", ref autoHideCombat)) + { + chatConfig.HideInCombat = autoHideCombat; + _chatConfigService.Save(); + UpdateHideState(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Temporarily hides the chat window while in combat."); + } + + var autoHideDuty = chatConfig.HideInDuty; + if (ImGui.Checkbox("Hide in duty (Not in field operations)", ref autoHideDuty)) + { + chatConfig.HideInDuty = autoHideDuty; + _chatConfigService.Save(); + UpdateHideState(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Hides the chat window inside duties."); + } + + var showWhenUiHidden = chatConfig.ShowWhenUiHidden; + if (ImGui.Checkbox("Show when game UI is hidden", ref showWhenUiHidden)) + { + chatConfig.ShowWhenUiHidden = showWhenUiHidden; + _chatConfigService.Save(); + UpdateHideState(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Allow the chat window to remain visible when the game UI is hidden."); + } + + var showInCutscenes = chatConfig.ShowInCutscenes; + if (ImGui.Checkbox("Show in cutscenes", ref showInCutscenes)) + { + chatConfig.ShowInCutscenes = showInCutscenes; + _chatConfigService.Save(); + UpdateHideState(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Allow the chat window to remain visible during cutscenes."); + } + + var showInGpose = chatConfig.ShowInGpose; + if (ImGui.Checkbox("Show in group pose", ref showInGpose)) + { + chatConfig.ShowInGpose = showInGpose; + _chatConfigService.Save(); + UpdateHideState(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Allow the chat window to remain visible in /gpose."); + } + + ImGui.Separator(); + var fontScale = Math.Clamp(chatConfig.ChatFontScale, MinChatFontScale, MaxChatFontScale); var fontScaleChanged = ImGui.SliderFloat("Message font scale", ref fontScale, MinChatFontScale, MaxChatFontScale, "%.2fx"); var resetFontScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); @@ -1244,7 +1394,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase }); } - private void DrawChannelButtons(IReadOnlyList channels) + private unsafe void DrawChannelButtons(IReadOnlyList channels) { var style = ImGui.GetStyle(); var baseFramePadding = style.FramePadding; @@ -1305,6 +1455,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { if (child) { + var dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left); + var hoveredTargetThisFrame = false; var first = true; foreach (var channel in channels) { @@ -1315,6 +1467,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var showBadge = !isSelected && channel.UnreadCount > 0; var isZoneChannel = channel.Type == ChatChannelType.Zone; (string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null; + var channelLabel = GetChannelTabLabel(channel); var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); var hovered = isSelected @@ -1343,7 +1496,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight); } - var clicked = ImGui.Button($"{channel.DisplayName}##chat_channel_{channel.Key}"); + var clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}"); if (showBadge) { @@ -1359,10 +1512,77 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _scrollToBottom = true; } + if (ShouldShowChannelTabContextMenu(channel) + && ImGui.BeginPopupContextItem($"chat_channel_ctx##{channel.Key}")) + { + DrawChannelTabContextMenu(channel); + ImGui.EndPopup(); + } + + if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.None)) + { + if (!string.Equals(_dragChannelKey, channel.Key, StringComparison.Ordinal)) + { + _dragHoverKey = null; + } + + _dragChannelKey = channel.Key; + ImGui.SetDragDropPayload(ChannelDragPayloadId, null, 0); + ImGui.TextUnformatted(channelLabel); + ImGui.EndDragDropSource(); + } + + var isDragTarget = false; + + if (ImGui.BeginDragDropTarget()) + { + var acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect; + var payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags); + if (!payload.IsNull && _dragChannelKey is { } draggedKey + && !string.Equals(draggedKey, channel.Key, StringComparison.Ordinal)) + { + isDragTarget = true; + if (!string.Equals(_dragHoverKey, channel.Key, StringComparison.Ordinal)) + { + _dragHoverKey = channel.Key; + _zoneChatService.MoveChannel(draggedKey, channel.Key); + } + } + + ImGui.EndDragDropTarget(); + } + + var isHoveredDuringDrag = dragActive + && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped); + + if (!isDragTarget && isHoveredDuringDrag + && !string.Equals(_dragChannelKey, channel.Key, StringComparison.Ordinal)) + { + isDragTarget = true; + if (!string.Equals(_dragHoverKey, channel.Key, StringComparison.Ordinal)) + { + _dragHoverKey = channel.Key; + _zoneChatService.MoveChannel(_dragChannelKey!, channel.Key); + } + } + var drawList = ImGui.GetWindowDrawList(); var itemMin = ImGui.GetItemRectMin(); var itemMax = ImGui.GetItemRectMax(); + if (isHoveredDuringDrag) + { + var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); + var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); + drawList.AddRectFilled(itemMin, itemMax, highlightU32, style.FrameRounding); + drawList.AddRect(itemMin, itemMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); + } + + if (isDragTarget) + { + hoveredTargetThisFrame = true; + } + if (isZoneChannel) { var borderColor = UIColors.Get("LightlessOrange"); @@ -1390,6 +1610,11 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase first = false; } + if (dragActive && !hoveredTargetThisFrame) + { + _dragHoverKey = null; + } + if (_pendingChannelScroll.HasValue) { ImGui.SetScrollX(_pendingChannelScroll.Value); @@ -1430,9 +1655,123 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _channelScroll = currentScroll; _channelScrollMax = maxScroll; + if (_dragChannelKey is not null && !ImGui.IsMouseDown(ImGuiMouseButton.Left)) + { + _dragChannelKey = null; + _dragHoverKey = null; + } + ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f); } + private string GetChannelTabLabel(ChatChannelSnapshot channel) + { + if (channel.Type != ChatChannelType.Group) + { + return channel.DisplayName; + } + + if (!_chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var preferNote) || !preferNote) + { + return channel.DisplayName; + } + + var note = GetChannelNote(channel); + if (string.IsNullOrWhiteSpace(note)) + { + return channel.DisplayName; + } + + return TruncateChannelNoteForTab(note); + } + + private static string TruncateChannelNoteForTab(string note) + { + if (note.Length <= MaxChannelNoteTabLength) + { + return note; + } + + var ellipsis = "..."; + var maxPrefix = Math.Max(0, MaxChannelNoteTabLength - ellipsis.Length); + return note[..maxPrefix] + ellipsis; + } + + private bool ShouldShowChannelTabContextMenu(ChatChannelSnapshot channel) + { + if (channel.Type != ChatChannelType.Group) + { + return false; + } + + if (_chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var preferNote) && preferNote) + { + return true; + } + + var note = GetChannelNote(channel); + return !string.IsNullOrWhiteSpace(note); + } + + private void DrawChannelTabContextMenu(ChatChannelSnapshot channel) + { + var preferNote = _chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var value) && value; + var note = GetChannelNote(channel); + var hasNote = !string.IsNullOrWhiteSpace(note); + if (preferNote || hasNote) + { + var label = preferNote ? "Prefer name instead" : "Prefer note instead"; + if (ImGui.MenuItem(label)) + { + SetPreferNoteForChannel(channel.Key, !preferNote); + } + } + + if (preferNote) + { + ImGui.Separator(); + ImGui.TextDisabled("Name:"); + ImGui.TextWrapped(channel.DisplayName); + } + + if (hasNote) + { + ImGui.Separator(); + ImGui.TextDisabled("Note:"); + ImGui.TextWrapped(note); + } + } + + private string? GetChannelNote(ChatChannelSnapshot channel) + { + if (channel.Type != ChatChannelType.Group) + { + return null; + } + + var gid = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(gid)) + { + return null; + } + + return _serverConfigurationManager.GetNoteForGid(gid); + } + + private void SetPreferNoteForChannel(string channelKey, bool preferNote) + { + if (preferNote) + { + _chatConfigService.Current.PreferNotesForChannels[channelKey] = true; + } + else + { + _chatConfigService.Current.PreferNotesForChannels.Remove(channelKey); + } + + _chatConfigService.Save(); + } + private void DrawSystemEntry(ChatMessageEntry entry) { var system = entry.SystemMessage; diff --git a/LightlessSync/WebAPI/SignalR/ApiController.cs b/LightlessSync/WebAPI/SignalR/ApiController.cs index af98de9..011a6d8 100644 --- a/LightlessSync/WebAPI/SignalR/ApiController.cs +++ b/LightlessSync/WebAPI/SignalR/ApiController.cs @@ -584,7 +584,10 @@ public sealed partial class ApiController : DisposableMediatorSubscriberBase, IL OnGroupSendInfo((dto) => _ = Client_GroupSendInfo(dto)); OnGroupUpdateProfile((dto) => _ = Client_GroupSendProfile(dto)); OnGroupChangeUserPairPermissions((dto) => _ = Client_GroupChangeUserPairPermissions(dto)); - _lightlessHub.On(nameof(Client_ChatReceive), (Func)Client_ChatReceive); + if (!_initialized) + { + _lightlessHub.On(nameof(Client_ChatReceive), (Func)Client_ChatReceive); + } OnGposeLobbyJoin((dto) => _ = Client_GposeLobbyJoin(dto)); OnGposeLobbyLeave((dto) => _ = Client_GposeLobbyLeave(dto)); -- 2.49.1 From 7c7a98f7708564c002be6099ecab2293635bc8da Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 01:19:17 +0900 Subject: [PATCH 130/140] This looks better --- LightlessSync/UI/ZoneChatUi.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 668bcb8..d45427c 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1720,7 +1720,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var hasNote = !string.IsNullOrWhiteSpace(note); if (preferNote || hasNote) { - var label = preferNote ? "Prefer name instead" : "Prefer note instead"; + var label = preferNote ? "Prefer Name Instead" : "Prefer Note Instead"; if (ImGui.MenuItem(label)) { SetPreferNoteForChannel(channel.Key, !preferNote); -- 2.49.1 From b99f68a8912331f0b84b40c37431a5f3835aef47 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 02:23:18 +0900 Subject: [PATCH 131/140] collapsible texture details --- LightlessSync/UI/DataAnalysisUi.cs | 211 ++++++++++++++++++++++++----- Penumbra.Api | 2 +- 2 files changed, 177 insertions(+), 36 deletions(-) diff --git a/LightlessSync/UI/DataAnalysisUi.cs b/LightlessSync/UI/DataAnalysisUi.cs index 2958ebc..a4bbf9f 100644 --- a/LightlessSync/UI/DataAnalysisUi.cs +++ b/LightlessSync/UI/DataAnalysisUi.cs @@ -29,6 +29,9 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private const float MaxTextureFilterPaneWidth = 405f; private const float MinTextureDetailPaneWidth = 480f; private const float MaxTextureDetailPaneWidth = 720f; + private const float TextureFilterSplitterWidth = 8f; + private const float TextureDetailSplitterWidth = 12f; + private const float TextureDetailSplitterCollapsedWidth = 18f; private const float SelectedFilePanelLogicalHeight = 90f; private static readonly Vector4 SelectedTextureRowTextColor = new(0f, 0f, 0f, 1f); @@ -80,6 +83,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private bool _modalOpen = false; private bool _showModal = false; private bool _textureRowsDirty = true; + private bool _textureDetailCollapsed = false; private bool _conversionFailed; private bool _showAlreadyAddedTransients = false; private bool _acknowledgeReview = false; @@ -1205,35 +1209,52 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var availableSize = ImGui.GetContentRegionAvail(); var windowPos = ImGui.GetWindowPos(); var spacingX = ImGui.GetStyle().ItemSpacing.X; - var splitterWidth = 6f * scale; + var filterSplitterWidth = TextureFilterSplitterWidth * scale; + var detailSplitterWidth = (_textureDetailCollapsed ? TextureDetailSplitterCollapsedWidth : TextureDetailSplitterWidth) * scale; + var totalSplitterWidth = filterSplitterWidth + detailSplitterWidth; + var totalSpacing = 2 * spacingX; const float minFilterWidth = MinTextureFilterPaneWidth; const float minDetailWidth = MinTextureDetailPaneWidth; const float minCenterWidth = 340f; - var dynamicFilterMax = Math.Max(minFilterWidth, availableSize.X - minDetailWidth - minCenterWidth - 2 * (splitterWidth + spacingX)); + var detailMinForLayout = _textureDetailCollapsed ? 0f : minDetailWidth; + var dynamicFilterMax = Math.Max(minFilterWidth, availableSize.X - detailMinForLayout - minCenterWidth - totalSplitterWidth - totalSpacing); var filterMaxBound = Math.Min(MaxTextureFilterPaneWidth, dynamicFilterMax); var filterWidth = Math.Clamp(_textureFilterPaneWidth, minFilterWidth, filterMaxBound); - var dynamicDetailMax = Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)); - var detailMaxBound = Math.Min(MaxTextureDetailPaneWidth, dynamicDetailMax); - var detailWidth = Math.Clamp(_textureDetailPaneWidth, minDetailWidth, detailMaxBound); + var dynamicDetailMax = Math.Max(detailMinForLayout, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing); + var detailMaxBound = _textureDetailCollapsed ? 0f : Math.Min(MaxTextureDetailPaneWidth, dynamicDetailMax); + var detailWidth = _textureDetailCollapsed ? 0f : Math.Clamp(_textureDetailPaneWidth, minDetailWidth, detailMaxBound); - var centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); + var centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { var deficit = minCenterWidth - centerWidth; - detailWidth = Math.Clamp(detailWidth - deficit, minDetailWidth, - Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); - centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); - if (centerWidth < minCenterWidth) + if (!_textureDetailCollapsed) + { + detailWidth = Math.Clamp(detailWidth - deficit, minDetailWidth, + Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); + centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; + if (centerWidth < minCenterWidth) + { + deficit = minCenterWidth - centerWidth; + filterWidth = Math.Clamp(filterWidth - deficit, minFilterWidth, + Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - detailWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); + detailWidth = Math.Clamp(detailWidth, minDetailWidth, + Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - totalSplitterWidth - totalSpacing))); + centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; + if (centerWidth < minCenterWidth) + { + centerWidth = minCenterWidth; + } + } + } + else { - deficit = minCenterWidth - centerWidth; filterWidth = Math.Clamp(filterWidth - deficit, minFilterWidth, - Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - detailWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); - detailWidth = Math.Clamp(detailWidth, minDetailWidth, - Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - filterWidth - minCenterWidth - 2 * (splitterWidth + spacingX)))); - centerWidth = availableSize.X - filterWidth - detailWidth - 2 * (splitterWidth + spacingX); + Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - totalSplitterWidth - totalSpacing))); + centerWidth = availableSize.X - filterWidth - detailWidth - totalSplitterWidth - totalSpacing; if (centerWidth < minCenterWidth) { centerWidth = minCenterWidth; @@ -1242,7 +1263,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } _textureFilterPaneWidth = filterWidth; - _textureDetailPaneWidth = detailWidth; + if (!_textureDetailCollapsed) + { + _textureDetailPaneWidth = detailWidth; + } ImGui.BeginGroup(); using (var filters = ImRaii.Child("textureFilters", new Vector2(filterWidth, 0), true)) @@ -1264,8 +1288,8 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var filterMax = ImGui.GetItemRectMax(); var filterHeight = filterMax.Y - filterMin.Y; var filterTopLocal = filterMin - windowPos; - var maxFilterResize = Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - minDetailWidth - 2 * (splitterWidth + spacingX))); - DrawVerticalResizeHandle("##textureFilterSplitter", filterTopLocal.Y, filterHeight, ref _textureFilterPaneWidth, minFilterWidth, maxFilterResize); + var maxFilterResize = Math.Min(MaxTextureFilterPaneWidth, Math.Max(minFilterWidth, availableSize.X - minCenterWidth - detailMinForLayout - totalSplitterWidth - totalSpacing)); + DrawVerticalResizeHandle("##textureFilterSplitter", filterTopLocal.Y, filterHeight, ref _textureFilterPaneWidth, minFilterWidth, maxFilterResize, out _); TextureRow? selectedRow; ImGui.BeginGroup(); @@ -1279,15 +1303,36 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase var tableMax = ImGui.GetItemRectMax(); var tableHeight = tableMax.Y - tableMin.Y; var tableTopLocal = tableMin - windowPos; - var maxDetailResize = Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - _textureFilterPaneWidth - minCenterWidth - 2 * (splitterWidth + spacingX))); - DrawVerticalResizeHandle("##textureDetailSplitter", tableTopLocal.Y, tableHeight, ref _textureDetailPaneWidth, minDetailWidth, maxDetailResize, invert: true); - - ImGui.BeginGroup(); - using (var detailChild = ImRaii.Child("textureDetailPane", new Vector2(detailWidth, 0), true)) + var maxDetailResize = Math.Min(MaxTextureDetailPaneWidth, Math.Max(minDetailWidth, availableSize.X - _textureFilterPaneWidth - minCenterWidth - totalSplitterWidth - totalSpacing)); + var detailToggle = DrawVerticalResizeHandle( + "##textureDetailSplitter", + tableTopLocal.Y, + tableHeight, + ref _textureDetailPaneWidth, + minDetailWidth, + maxDetailResize, + out var detailDragging, + invert: true, + showToggle: true, + isCollapsed: _textureDetailCollapsed); + if (detailToggle) { - DrawTextureDetail(selectedRow); + _textureDetailCollapsed = !_textureDetailCollapsed; + } + if (_textureDetailCollapsed && detailDragging) + { + _textureDetailCollapsed = false; + } + + if (!_textureDetailCollapsed) + { + ImGui.BeginGroup(); + using (var detailChild = ImRaii.Child("textureDetailPane", new Vector2(detailWidth, 0), true)) + { + DrawTextureDetail(selectedRow); + } + ImGui.EndGroup(); } - ImGui.EndGroup(); } private void DrawTextureFilters( @@ -1935,26 +1980,118 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } - private void DrawVerticalResizeHandle(string id, float topY, float height, ref float leftWidth, float minWidth, float maxWidth, bool invert = false) + private bool DrawVerticalResizeHandle( + string id, + float topY, + float height, + ref float leftWidth, + float minWidth, + float maxWidth, + out bool isDragging, + bool invert = false, + bool showToggle = false, + bool isCollapsed = false) { var scale = ImGuiHelpers.GlobalScale; - var splitterWidth = 8f * scale; + var splitterWidth = (showToggle + ? (isCollapsed ? TextureDetailSplitterCollapsedWidth : TextureDetailSplitterWidth) + : TextureFilterSplitterWidth) * scale; ImGui.SameLine(); var cursor = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(cursor.X, topY)); - ImGui.PushStyleColor(ImGuiCol.Button, UIColors.Get("ButtonDefault")); - ImGui.PushStyleColor(ImGuiCol.ButtonHovered, UIColors.Get("LightlessPurple")); - ImGui.PushStyleColor(ImGuiCol.ButtonActive, UIColors.Get("LightlessPurpleActive")); - ImGui.Button(id, new Vector2(splitterWidth, height)); - ImGui.PopStyleColor(3); + var contentMin = ImGui.GetWindowContentRegionMin(); + var contentMax = ImGui.GetWindowContentRegionMax(); + var clampedTop = MathF.Max(topY, contentMin.Y); + var clampedBottom = MathF.Min(topY + height, contentMax.Y); + var clampedHeight = MathF.Max(0f, clampedBottom - clampedTop); + var splitterRounding = ImGui.GetStyle().FrameRounding; + ImGui.SetCursorPos(new Vector2(cursor.X, clampedTop)); + if (clampedHeight <= 0f) + { + isDragging = false; + ImGui.SetCursorPos(new Vector2(cursor.X + splitterWidth + ImGui.GetStyle().ItemSpacing.X, cursor.Y)); + return false; + } - if (ImGui.IsItemActive()) + ImGui.InvisibleButton(id, new Vector2(splitterWidth, clampedHeight)); + var drawList = ImGui.GetWindowDrawList(); + var rectMin = ImGui.GetItemRectMin(); + var rectMax = ImGui.GetItemRectMax(); + var windowPos = ImGui.GetWindowPos(); + var clipMin = windowPos + contentMin; + var clipMax = windowPos + contentMax; + drawList.PushClipRect(clipMin, clipMax, true); + var clipInset = 1f * scale; + var drawMin = new Vector2( + MathF.Max(rectMin.X, clipMin.X), + MathF.Max(rectMin.Y, clipMin.Y)); + var drawMax = new Vector2( + MathF.Min(rectMax.X, clipMax.X - clipInset), + MathF.Min(rectMax.Y, clipMax.Y)); + var hovered = ImGui.IsItemHovered(); + isDragging = ImGui.IsItemActive(); + var baseColor = UIColors.Get("ButtonDefault"); + var hoverColor = UIColors.Get("LightlessPurple"); + var activeColor = UIColors.Get("LightlessPurpleActive"); + var handleColor = isDragging ? activeColor : hovered ? hoverColor : baseColor; + drawList.AddRectFilled(drawMin, drawMax, UiSharedService.Color(handleColor), splitterRounding); + drawList.AddRect(drawMin, drawMax, UiSharedService.Color(new Vector4(1f, 1f, 1f, 0.12f)), splitterRounding); + + bool toggleHovered = false; + bool toggleClicked = false; + if (showToggle) + { + var icon = isCollapsed ? FontAwesomeIcon.ChevronRight : FontAwesomeIcon.ChevronLeft; + Vector2 iconSize; + using (_uiSharedService.IconFont.Push()) + { + iconSize = ImGui.CalcTextSize(icon.ToIconString()); + } + + var toggleHeight = MathF.Min(clampedHeight, 64f * scale); + var toggleMin = new Vector2( + drawMin.X, + drawMin.Y + (drawMax.Y - drawMin.Y - toggleHeight) / 2f); + var toggleMax = new Vector2( + drawMax.X, + toggleMin.Y + toggleHeight); + var toggleColorBase = UIColors.Get("LightlessPurple"); + toggleHovered = ImGui.IsMouseHoveringRect(toggleMin, toggleMax); + var toggleBg = toggleHovered + ? new Vector4(toggleColorBase.X, toggleColorBase.Y, toggleColorBase.Z, 0.65f) + : new Vector4(toggleColorBase.X, toggleColorBase.Y, toggleColorBase.Z, 0.35f); + if (toggleHovered) + { + UiSharedService.AttachToolTip(isCollapsed ? "Show texture details." : "Hide texture details."); + } + + drawList.AddRectFilled(toggleMin, toggleMax, UiSharedService.Color(toggleBg), splitterRounding); + drawList.AddRect(toggleMin, toggleMax, UiSharedService.Color(toggleColorBase), splitterRounding); + + var iconPos = new Vector2( + drawMin.X + (drawMax.X - drawMin.X - iconSize.X) / 2f, + drawMin.Y + (drawMax.Y - drawMin.Y - iconSize.Y) / 2f); + using (_uiSharedService.IconFont.Push()) + { + drawList.AddText(iconPos, ImGui.GetColorU32(ImGuiCol.Text), icon.ToIconString()); + } + + if (toggleHovered && ImGui.IsMouseReleased(ImGuiMouseButton.Left) && !ImGui.IsMouseDragging(ImGuiMouseButton.Left)) + { + toggleClicked = true; + } + } + + if (isDragging && !toggleHovered) { var delta = ImGui.GetIO().MouseDelta.X / scale; leftWidth += invert ? -delta : delta; leftWidth = Math.Clamp(leftWidth, minWidth, maxWidth); } + + drawList.PopClipRect(); + ImGui.SetCursorPos(new Vector2(cursor.X + splitterWidth + ImGui.GetStyle().ItemSpacing.X, cursor.Y)); + return toggleClicked; } private (IDalamudTextureWrap? Texture, bool IsLoading, string? Error) GetTexturePreview(TextureRow row) @@ -2094,7 +2231,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } else { - ImGui.TextDisabled("-"); + _uiSharedService.IconText(FontAwesomeIcon.Check, ImGuiColors.DalamudWhite); UiSharedService.AttachToolTip("Already stored in a compressed format; additional compression is disabled."); } @@ -2175,6 +2312,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase _textureSelections[key] = target; currentSelection = target; } + if (TextureMetadataHelper.TryGetRecommendationInfo(target, out var targetInfo)) + { + UiSharedService.AttachToolTip($"{targetInfo.Title}{UiSharedService.TooltipSeparator}{targetInfo.Description}"); + } if (targetSelected) { ImGui.SetItemDefaultFocus(); diff --git a/Penumbra.Api b/Penumbra.Api index 1750c41..52a3216 160000 --- a/Penumbra.Api +++ b/Penumbra.Api @@ -1 +1 @@ -Subproject commit 1750c41b53e1000c99a7fb9d8a0f082aef639a41 +Subproject commit 52a3216a525592205198303df2844435e382cf87 -- 2.49.1 From 03105e0755b7c842bd81dc5314bd72eec9b43eb4 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 04:28:36 +0900 Subject: [PATCH 132/140] fix log level --- LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs index 544ada1..d78563c 100644 --- a/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs +++ b/LightlessSync/Services/LightFinder/LightFinderPlateHandler.cs @@ -641,8 +641,8 @@ public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscribe return; _activeBroadcastingCids = newSet; - if (_logger.IsEnabled(LogLevel.Information)) - _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Active broadcast IDs: {Cids}", string.Join(',', _activeBroadcastingCids)); FlagRefresh(); } -- 2.49.1 From 54530cb16d758439ce93fda2886394ef00de8bbe Mon Sep 17 00:00:00 2001 From: defnotken Date: Sat, 20 Dec 2025 15:23:36 -0600 Subject: [PATCH 133/140] fixing a typo for help message --- LightlessSync/Services/CommandManagerService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LightlessSync/Services/CommandManagerService.cs b/LightlessSync/Services/CommandManagerService.cs index d42a865..0014b3a 100644 --- a/LightlessSync/Services/CommandManagerService.cs +++ b/LightlessSync/Services/CommandManagerService.cs @@ -49,7 +49,7 @@ public sealed class CommandManagerService : IDisposable "\t /light analyze - Opens the Lightless Character Data Analysis window" + Environment.NewLine + "\t /light settings - Opens the Lightless Settings window" + Environment.NewLine + "\t /light finder - Opens the Lightfinder window" + Environment.NewLine + - "\t /light finder - Opens the Lightless Chat window" + "\t /light chat - Opens the Lightless Chat window" }); } -- 2.49.1 From 779ff06981d1fb8bcf132bbfdd58098679e02f02 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 07:26:37 +0900 Subject: [PATCH 134/140] goodbye lag --- .../TextureCompression/TextureMetadataHelper.cs | 10 +++++----- LightlessSync/UI/Components/DrawFolderBase.cs | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs index f360ba3..20d9a8f 100644 --- a/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs +++ b/LightlessSync/Services/TextureCompression/TextureMetadataHelper.cs @@ -126,11 +126,11 @@ public sealed class TextureMetadataHelper private const string TextureSegment = "/texture/"; private const string MaterialSegment = "/material/"; - private const uint NormalSamplerId = 0x0C5EC1F1u; - private const uint IndexSamplerId = 0x565F8FD8u; - private const uint SpecularSamplerId = 0x2B99E025u; - private const uint DiffuseSamplerId = 0x115306BEu; - private const uint MaskSamplerId = 0x8A4E82B6u; + private const uint NormalSamplerId = ShpkFile.NormalSamplerId; + private const uint IndexSamplerId = ShpkFile.IndexSamplerId; + private const uint SpecularSamplerId = ShpkFile.SpecularSamplerId; + private const uint DiffuseSamplerId = ShpkFile.DiffuseSamplerId; + private const uint MaskSamplerId = ShpkFile.MaskSamplerId; public TextureMetadataHelper(ILogger logger, IDataManager dataManager) { diff --git a/LightlessSync/UI/Components/DrawFolderBase.cs b/LightlessSync/UI/Components/DrawFolderBase.cs index 40330c7..0532da9 100644 --- a/LightlessSync/UI/Components/DrawFolderBase.cs +++ b/LightlessSync/UI/Components/DrawFolderBase.cs @@ -4,8 +4,8 @@ using Dalamud.Interface.Utility.Raii; using LightlessSync.UI.Handlers; using LightlessSync.UI.Models; using System.Collections.Immutable; -using LightlessSync.UI; using LightlessSync.UI.Style; +using OtterGui.Text; namespace LightlessSync.UI.Components; @@ -113,9 +113,13 @@ public abstract class DrawFolderBase : IDrawFolder using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false); if (DrawPairs.Any()) { - foreach (var item in DrawPairs) + using var clipper = ImUtf8.ListClipper(DrawPairs.Count, ImGui.GetFrameHeightWithSpacing()); + while (clipper.Step()) { - item.DrawPairedClient(); + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + { + DrawPairs[i].DrawPairedClient(); + } } } else -- 2.49.1 From 79539e3db8999ccd0a7e78acea4cafdb976d7838 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 07:55:41 +0900 Subject: [PATCH 135/140] moderators will love this one --- LightlessSync/UI/LightFinderUI.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/LightlessSync/UI/LightFinderUI.cs b/LightlessSync/UI/LightFinderUI.cs index ca74bc9..22911cb 100644 --- a/LightlessSync/UI/LightFinderUI.cs +++ b/LightlessSync/UI/LightFinderUI.cs @@ -301,6 +301,14 @@ namespace LightlessSync.UI bool ShellFinderEnabled = _configService.Current.SyncshellFinderEnabled; bool isBroadcasting = _broadcastService.IsBroadcasting; + if (isBroadcasting) + { + var warningColor = UIColors.Get("LightlessYellow"); + _uiSharedService.DrawNoteLine("! ", warningColor, + new SeStringUtils.RichTextEntry("Syncshell Finder can only be changed while Lightfinder is disabled.", warningColor)); + ImGuiHelpers.ScaledDummy(0.2f); + } + if (isBroadcasting) ImGui.BeginDisabled(); -- 2.49.1 From f2b17120fa398bf4a42b11bd4458b013ae332c96 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 09:00:34 +0900 Subject: [PATCH 136/140] implement focus fade for chat --- .../Configurations/ChatConfig.cs | 2 + LightlessSync/UI/ZoneChatUi.cs | 217 +++++++++++++++--- 2 files changed, 192 insertions(+), 27 deletions(-) diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index dcdfc78..f438c45 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -11,6 +11,8 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowRulesOverlayOnOpen { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true; public float ChatWindowOpacity { get; set; } = .97f; + public bool FadeWhenUnfocused { get; set; } = false; + public float UnfocusedWindowOpacity { get; set; } = 0.6f; public bool IsWindowPinned { get; set; } = false; public bool AutoOpenChatOnPluginLoad { get; set; } = false; public float ChatFontScale { get; set; } = 1.0f; diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index d45427c..396e63c 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -11,9 +11,11 @@ using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Chat; +using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.ServerConfiguration; using LightlessSync.UI.Services; +using LightlessSync.UI.Style; using LightlessSync.Utils; using LightlessSync.WebAPI; using LightlessSync.WebAPI.SignalR.Utils; @@ -29,10 +31,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const string ReportPopupId = "Report Message##zone_chat_report_popup"; private const string ChannelDragPayloadId = "zone_chat_channel_drag"; private const float DefaultWindowOpacity = .97f; + private const float DefaultUnfocusedWindowOpacity = 0.6f; private const float MinWindowOpacity = 0.05f; private const float MaxWindowOpacity = 1f; private const float MinChatFontScale = 0.75f; private const float MaxChatFontScale = 1.5f; + private const float UnfocusedFadeOutSpeed = 0.22f; + private const float FocusFadeInSpeed = 2.0f; private const int ReportReasonMaxLength = 500; private const int ReportContextMaxLength = 1000; private const int MaxChannelNoteTabLength = 25; @@ -40,6 +45,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; private readonly PairUiService _pairUiService; + private readonly LightFinderService _lightFinderService; private readonly LightlessProfileManager _profileManager; private readonly ApiController _apiController; private readonly ChatConfigService _chatConfigService; @@ -49,16 +55,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; private float _currentWindowOpacity = DefaultWindowOpacity; + private float _baseWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; private bool _showRulesOverlay; private bool _refocusChatInput; private string? _refocusChatInputKey; + private bool _isWindowFocused = true; + private int _titleBarStylePopCount; private string? _selectedChannelKey; private bool _scrollToBottom = true; private float? _pendingChannelScroll; private float _channelScroll; private float _channelScrollMax; + private readonly SeluneBrush _seluneBrush = new(); private ChatChannelSnapshot? _reportTargetChannel; private ChatMessageEntry? _reportTargetMessage; private string _reportReason = string.Empty; @@ -79,6 +89,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase UiSharedService uiSharedService, ZoneChatService zoneChatService, PairUiService pairUiService, + LightFinderService lightFinderService, LightlessProfileManager profileManager, ChatConfigService chatConfigService, ServerConfigurationManager serverConfigurationManager, @@ -91,6 +102,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _uiSharedService = uiSharedService; _zoneChatService = zoneChatService; _pairUiService = pairUiService; + _lightFinderService = lightFinderService; _profileManager = profileManager; _chatConfigService = chatConfigService; _serverConfigurationManager = serverConfigurationManager; @@ -124,8 +136,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { RefreshWindowFlags(); base.PreDraw(); - _currentWindowOpacity = Math.Clamp(_chatConfigService.Current.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var config = _chatConfigService.Current; + var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + _baseWindowOpacity = baseOpacity; + + if (config.FadeWhenUnfocused) + { + var unfocusedOpacity = Math.Clamp(config.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var targetOpacity = _isWindowFocused ? baseOpacity : Math.Min(baseOpacity, unfocusedOpacity); + var delta = ImGui.GetIO().DeltaTime; + var speed = _isWindowFocused ? FocusFadeInSpeed : UnfocusedFadeOutSpeed; + _currentWindowOpacity = MoveTowards(_currentWindowOpacity, targetOpacity, speed * delta); + } + else + { + _currentWindowOpacity = baseOpacity; + } + ImGui.SetNextWindowBgAlpha(_currentWindowOpacity); + PushTitleBarFadeColors(_currentWindowOpacity); } private void UpdateHideState() @@ -179,8 +208,36 @@ 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); + if (config.FadeWhenUnfocused && isHovered && !isFocused) + { + ImGui.SetWindowFocus(); + } + + _isWindowFocused = config.FadeWhenUnfocused ? (isFocused || isHovered) : isFocused; + + var contentAlpha = 1f; + if (config.FadeWhenUnfocused) + { + var baseOpacity = MathF.Max(_baseWindowOpacity, 0.001f); + contentAlpha = Math.Clamp(_currentWindowOpacity / baseOpacity, 0f, 1f); + } + + using var alpha = ImRaii.PushStyle(ImGuiStyleVar.Alpha, contentAlpha); + var drawList = ImGui.GetWindowDrawList(); + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; - childBgColor.W *= _currentWindowOpacity; + childBgColor.W *= _baseWindowOpacity; using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor); DrawConnectionControls(); @@ -192,36 +249,58 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); ImGui.TextWrapped("No chat channels available."); ImGui.PopStyleColor(); - return; } - - EnsureSelectedChannel(channels); - CleanupDrafts(channels); - - DrawChannelButtons(channels); - - if (_selectedChannelKey is null) - return; - - var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); - if (activeChannel.Equals(default(ChatChannelSnapshot))) + else { - activeChannel = channels[0]; - _selectedChannelKey = activeChannel.Key; + EnsureSelectedChannel(channels); + CleanupDrafts(channels); + + DrawChannelButtons(channels); + + if (_selectedChannelKey is null) + { + selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + return; + } + + var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); + if (activeChannel.Equals(default(ChatChannelSnapshot))) + { + activeChannel = channels[0]; + _selectedChannelKey = activeChannel.Key; + } + + _zoneChatService.SetActiveChannel(activeChannel.Key); + + DrawHeader(activeChannel); + ImGui.Separator(); + DrawMessageArea(activeChannel, _currentWindowOpacity); + ImGui.Separator(); + DrawInput(activeChannel); } - _zoneChatService.SetActiveChannel(activeChannel.Key); - - DrawHeader(activeChannel); - ImGui.Separator(); - DrawMessageArea(activeChannel, _currentWindowOpacity); - ImGui.Separator(); - DrawInput(activeChannel); - if (_showRulesOverlay) { DrawRulesOverlay(); } + + selune.DrawHighlightOnly(ImGui.GetIO().DeltaTime); + } + + private void PushTitleBarFadeColors(float opacity) + { + _titleBarStylePopCount = 0; + var alpha = Math.Clamp(opacity, 0f, 1f); + var colors = ImGui.GetStyle().Colors; + + var titleBg = colors[(int)ImGuiCol.TitleBg]; + var titleBgActive = colors[(int)ImGuiCol.TitleBgActive]; + var titleBgCollapsed = colors[(int)ImGuiCol.TitleBgCollapsed]; + + ImGui.PushStyleColor(ImGuiCol.TitleBg, new Vector4(titleBg.X, titleBg.Y, titleBg.Z, titleBg.W * alpha)); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, new Vector4(titleBgActive.X, titleBgActive.Y, titleBgActive.Z, titleBgActive.W * alpha)); + ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, new Vector4(titleBgCollapsed.X, titleBgCollapsed.Y, titleBgCollapsed.Z, titleBgCollapsed.W * alpha)); + _titleBarStylePopCount = 3; } private void DrawHeader(ChatChannelSnapshot channel) @@ -1119,18 +1198,56 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var groupSize = ImGui.GetItemRectSize(); var minBlockX = cursorStart.X + groupSize.X + style.ItemSpacing.X; var availableAfterGroup = contentRightX - (cursorStart.X + groupSize.X); + var lightfinderButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.PersonCirclePlus).X; var settingsButtonWidth = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog).X; var pinIcon = _isWindowPinned ? FontAwesomeIcon.Lock : FontAwesomeIcon.Unlock; var pinButtonWidth = _uiSharedService.GetIconButtonSize(pinIcon).X; - var blockWidth = rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth; + var blockWidth = lightfinderButtonWidth + style.ItemSpacing.X + rulesButtonWidth + style.ItemSpacing.X + settingsButtonWidth + style.ItemSpacing.X + pinButtonWidth; var desiredBlockX = availableAfterGroup > blockWidth + style.ItemSpacing.X ? contentRightX - blockWidth : minBlockX; desiredBlockX = Math.Max(cursorStart.X, desiredBlockX); - var rulesPos = new Vector2(desiredBlockX, cursorStart.Y); - var settingsPos = new Vector2(desiredBlockX + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y); + var lightfinderPos = new Vector2(desiredBlockX, cursorStart.Y); + var rulesPos = new Vector2(lightfinderPos.X + lightfinderButtonWidth + style.ItemSpacing.X, cursorStart.Y); + var settingsPos = new Vector2(rulesPos.X + rulesButtonWidth + style.ItemSpacing.X, cursorStart.Y); var pinPos = new Vector2(settingsPos.X + settingsButtonWidth + style.ItemSpacing.X, cursorStart.Y); + ImGui.SameLine(); + ImGui.SetCursorPos(lightfinderPos); + var lightfinderEnabled = _lightFinderService.IsBroadcasting; + var lightfinderColor = lightfinderEnabled ? UIColors.Get("LightlessGreen") : ImGuiColors.DalamudGrey3; + var lightfinderButtonSize = new Vector2(lightfinderButtonWidth, ImGui.GetFrameHeight()); + ImGui.InvisibleButton("zone_chat_lightfinder_button", lightfinderButtonSize); + var lightfinderMin = ImGui.GetItemRectMin(); + var lightfinderMax = ImGui.GetItemRectMax(); + var iconSize = _uiSharedService.GetIconSize(FontAwesomeIcon.PersonCirclePlus); + var iconPos = new Vector2( + lightfinderMin.X + (lightfinderButtonSize.X - iconSize.X) * 0.5f, + lightfinderMin.Y + (lightfinderButtonSize.Y - iconSize.Y) * 0.5f); + using (_uiSharedService.IconFont.Push()) + { + ImGui.GetWindowDrawList().AddText(iconPos, ImGui.GetColorU32(lightfinderColor), FontAwesomeIcon.PersonCirclePlus.ToIconString()); + } + + if (ImGui.IsItemClicked()) + { + Mediator.Publish(new UiToggleMessage(typeof(LightFinderUI))); + } + if (ImGui.IsItemHovered()) + { + var padding = new Vector2(8f * ImGuiHelpers.GlobalScale); + Selune.RegisterHighlight( + lightfinderMin - padding, + lightfinderMax + padding, + SeluneHighlightMode.Point, + exactSize: true, + clipToElement: true, + clipPadding: padding, + highlightColorOverride: lightfinderColor, + highlightAlphaOverride: 0.2f); + ImGui.SetTooltip("If Lightfinder is enabled, you will be able to see the character names of other Lightfinder users in the same zone when they send a message."); + } + ImGui.SameLine(); ImGui.SetCursorPos(rulesPos); if (ImGui.Button("Rules", new Vector2(rulesButtonWidth, 0f))) @@ -1376,9 +1493,55 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("Adjust chat window transparency.\nRight-click to reset to default."); } + var fadeUnfocused = chatConfig.FadeWhenUnfocused; + if (ImGui.Checkbox("Fade window when unfocused", ref fadeUnfocused)) + { + chatConfig.FadeWhenUnfocused = fadeUnfocused; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus."); + } + + ImGui.BeginDisabled(!fadeUnfocused); + var unfocusedOpacity = Math.Clamp(chatConfig.UnfocusedWindowOpacity, MinWindowOpacity, MaxWindowOpacity); + var unfocusedChanged = ImGui.SliderFloat("Unfocused transparency", ref unfocusedOpacity, MinWindowOpacity, MaxWindowOpacity, "%.2f"); + var resetUnfocused = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetUnfocused) + { + unfocusedOpacity = DefaultUnfocusedWindowOpacity; + unfocusedChanged = true; + } + if (unfocusedChanged) + { + chatConfig.UnfocusedWindowOpacity = unfocusedOpacity; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Target transparency while the chat window is unfocused.\nRight-click to reset to default."); + } + ImGui.EndDisabled(); + ImGui.EndPopup(); } + private static float MoveTowards(float current, float target, float maxDelta) + { + if (current < target) + { + return MathF.Min(current + maxDelta, target); + } + + if (current > target) + { + return MathF.Max(current - maxDelta, target); + } + + return target; + } + private void ToggleChatConnection(bool currentlyEnabled) { _ = Task.Run(async () => -- 2.49.1 From f225989a00e4839f515b34c9a84fc9639d707eb6 Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 21 Dec 2025 01:20:34 +0100 Subject: [PATCH 137/140] Trunculate the broadcaster name in the grid view as it was overlapping into the Shell name --- LightlessSync/UI/SyncshellFinderUI.cs | 84 ++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/LightlessSync/UI/SyncshellFinderUI.cs b/LightlessSync/UI/SyncshellFinderUI.cs index 2f215a1..0586c06 100644 --- a/LightlessSync/UI/SyncshellFinderUI.cs +++ b/LightlessSync/UI/SyncshellFinderUI.cs @@ -350,9 +350,9 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase ? shell.Group.Alias : shell.Group.GID; + var style = ImGui.GetStyle(); float startX = ImGui.GetCursorPosX(); - float availWidth = ImGui.GetContentRegionAvail().X; - float rightTextW = ImGui.CalcTextSize(broadcasterName).X; + float availW = ImGui.GetContentRegionAvail().X; ImGui.BeginGroup(); @@ -364,13 +364,45 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase Mediator.Publish(new GroupProfileOpenStandaloneMessage(shell.Group)); } - ImGui.SameLine(); - float rightX = startX + availWidth - rightTextW; - var pos = ImGui.GetCursorPos(); - ImGui.SetCursorPos(new Vector2(rightX, pos.Y + 3f * ImGuiHelpers.GlobalScale)); - ImGui.TextUnformatted(broadcasterName); - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Broadcaster of the syncshell."); + float nameRightX = ImGui.GetItemRectMax().X; + + var regionMinScreen = ImGui.GetCursorScreenPos(); + float regionRightX = regionMinScreen.X + availW; + + float minBroadcasterX = nameRightX + style.ItemSpacing.X; + + float maxBroadcasterWidth = regionRightX - minBroadcasterX; + + string broadcasterToShow = broadcasterName; + + if (!string.IsNullOrEmpty(broadcasterName) && maxBroadcasterWidth > 0f) + { + float bcFullWidth = ImGui.CalcTextSize(broadcasterName).X; + string toolTip; + + if (bcFullWidth > maxBroadcasterWidth) + { + broadcasterToShow = TruncateTextToWidth(broadcasterName, maxBroadcasterWidth); + toolTip = broadcasterName + Environment.NewLine + Environment.NewLine + "Broadcaster of the syncshell."; + } + else + { + toolTip = "Broadcaster of the syncshell."; + } + + float bcWidth = ImGui.CalcTextSize(broadcasterToShow).X; + + float broadX = regionRightX - bcWidth; + + broadX = MathF.Max(broadX, minBroadcasterX); + + ImGui.SameLine(); + var curPos = ImGui.GetCursorPos(); + ImGui.SetCursorPos(new Vector2(broadX - regionMinScreen.X + startX, curPos.Y + 3f * ImGuiHelpers.GlobalScale)); + ImGui.TextUnformatted(broadcasterToShow); + if (ImGui.IsItemHovered()) + ImGui.SetTooltip(toolTip); + } ImGui.EndGroup(); @@ -590,6 +622,40 @@ public class SyncshellFinderUI : WindowMediatorSubscriberBase float widthUsed = cursorLocalX - baseLocal.X; return (widthUsed, rowHeight); } + private static string TruncateTextToWidth(string text, float maxWidth) + { + if (string.IsNullOrEmpty(text)) + return text; + + const string ellipsis = "..."; + float ellipsisWidth = ImGui.CalcTextSize(ellipsis).X; + + if (maxWidth <= ellipsisWidth) + return ellipsis; + + int low = 0; + int high = text.Length; + string best = ellipsis; + + while (low <= high) + { + int mid = (low + high) / 2; + string candidate = string.Concat(text.AsSpan(0, mid), ellipsis); + float width = ImGui.CalcTextSize(candidate).X; + + if (width <= maxWidth) + { + best = candidate; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return best; + } private IDalamudTextureWrap? GetIconWrap(uint iconId) { -- 2.49.1 From 7b74fa7c4eb8eb2a86add55391adb1989cdb9fcf Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 21 Dec 2025 01:55:26 +0100 Subject: [PATCH 138/140] Attempt to have a minute grace whenever collection get removed. --- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 66 ++++++------- LightlessSync/PlayerData/Pairs/Pair.cs | 11 ++- .../PlayerData/Pairs/PairDebugInfo.cs | 6 ++ .../PlayerData/Pairs/PairHandlerAdapter.cs | 96 +++++++++++++++---- .../PlayerData/Pairs/PairHandlerRegistry.cs | 35 +++++++ LightlessSync/UI/SettingsUi.cs | 16 ++++ 6 files changed, 180 insertions(+), 50 deletions(-) diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index a7bd80c..5561bfe 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,36 +1,38 @@ -using LightlessSync.API.Data; + using LightlessSync.API.Data; -namespace LightlessSync.PlayerData.Pairs; + namespace LightlessSync.PlayerData.Pairs; -/// -/// orchestrates the lifecycle of a paired character -/// -public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject -{ - new string Ident { get; } - bool Initialized { get; } - bool IsVisible { get; } - bool ScheduledForDeletion { get; set; } - CharacterData? LastReceivedCharacterData { get; } - long LastAppliedDataBytes { get; } - new string? PlayerName { get; } - string PlayerNameHash { get; } - uint PlayerCharacterId { get; } - DateTime? LastDataReceivedAt { get; } - DateTime? LastApplyAttemptAt { get; } - DateTime? LastSuccessfulApplyAt { get; } - string? LastFailureReason { get; } - IReadOnlyList LastBlockingConditions { get; } - bool IsApplying { get; } - bool IsDownloading { get; } - int PendingDownloadCount { get; } - int ForbiddenDownloadCount { get; } + /// + /// orchestrates the lifecycle of a paired character + /// + public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject + { + new string Ident { get; } + bool Initialized { get; } + bool IsVisible { get; } + bool ScheduledForDeletion { get; set; } + CharacterData? LastReceivedCharacterData { get; } + long LastAppliedDataBytes { get; } + new string? PlayerName { get; } + string PlayerNameHash { get; } + uint PlayerCharacterId { get; } + DateTime? LastDataReceivedAt { get; } + DateTime? LastApplyAttemptAt { get; } + DateTime? LastSuccessfulApplyAt { get; } + string? LastFailureReason { get; } + IReadOnlyList LastBlockingConditions { get; } + bool IsApplying { get; } + bool IsDownloading { get; } + int PendingDownloadCount { get; } + int ForbiddenDownloadCount { get; } + DateTime? InvisibleSinceUtc { get; } + DateTime? VisibilityEvictionDueAtUtc { get; } void Initialize(); - void ApplyData(CharacterData data); - void ApplyLastReceivedData(bool forced = false); - bool FetchPerformanceMetricsFromCache(); - void LoadCachedCharacterData(CharacterData data); - void SetUploading(bool uploading); - void SetPaused(bool paused); -} + void ApplyData(CharacterData data); + void ApplyLastReceivedData(bool forced = false); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); + } diff --git a/LightlessSync/PlayerData/Pairs/Pair.cs b/LightlessSync/PlayerData/Pairs/Pair.cs index 7d780dd..935b705 100644 --- a/LightlessSync/PlayerData/Pairs/Pair.cs +++ b/LightlessSync/PlayerData/Pairs/Pair.cs @@ -194,9 +194,13 @@ public class Pair { var handler = TryGetHandler(); if (handler is null) - { return PairDebugInfo.Empty; - } + + var now = DateTime.UtcNow; + var dueAt = handler.VisibilityEvictionDueAtUtc; + var remainingSeconds = dueAt.HasValue + ? Math.Max(0, (dueAt.Value - now).TotalSeconds) + : (double?)null; return new PairDebugInfo( true, @@ -206,6 +210,9 @@ public class Pair handler.LastDataReceivedAt, handler.LastApplyAttemptAt, handler.LastSuccessfulApplyAt, + handler.InvisibleSinceUtc, + handler.VisibilityEvictionDueAtUtc, + remainingSeconds, handler.LastFailureReason, handler.LastBlockingConditions, handler.IsApplying, diff --git a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs index 9074c82..31c3236 100644 --- a/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs +++ b/LightlessSync/PlayerData/Pairs/PairDebugInfo.cs @@ -8,6 +8,9 @@ public sealed record PairDebugInfo( DateTime? LastDataReceivedAt, DateTime? LastApplyAttemptAt, DateTime? LastSuccessfulApplyAt, + DateTime? InvisibleSinceUtc, + DateTime? VisibilityEvictionDueAtUtc, + double? VisibilityEvictionRemainingSeconds, string? LastFailureReason, IReadOnlyList BlockingConditions, bool IsApplying, @@ -24,6 +27,9 @@ public sealed record PairDebugInfo( null, null, null, + null, + null, + null, Array.Empty(), false, false, diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index 556dd84..706b0bc 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -70,7 +70,14 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private DateTime? _lastSuccessfulApplyAt; private string? _lastFailureReason; private IReadOnlyList _lastBlockingConditions = Array.Empty(); + private readonly object _visibilityGraceGate = new(); + private CancellationTokenSource? _visibilityGraceCts; + private static readonly TimeSpan VisibilityEvictionGrace = TimeSpan.FromMinutes(1); + private DateTime? _invisibleSinceUtc; + private DateTime? _visibilityEvictionDueAtUtc; + public DateTime? InvisibleSinceUtc => _invisibleSinceUtc; + public DateTime? VisibilityEvictionDueAtUtc => _visibilityEvictionDueAtUtc; public string Ident { get; } public bool Initialized { get; private set; } public bool ScheduledForDeletion { get; set; } @@ -80,24 +87,37 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa get => _isVisible; private set { - if (_isVisible != value) + if (_isVisible == value) return; + + _isVisible = value; + + if (!_isVisible) { - _isVisible = value; - if (!_isVisible) - { - DisableSync(); - ResetPenumbraCollection(reason: "VisibilityLost"); - } - else if (_charaHandler is not null && _charaHandler.Address != nint.Zero) - { - _ = EnsurePenumbraCollection(); - } - var user = GetPrimaryUserData(); - Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), - EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); - Mediator.Publish(new RefreshUiMessage()); - Mediator.Publish(new VisibilityChange()); + DisableSync(); + + _invisibleSinceUtc = DateTime.UtcNow; + _visibilityEvictionDueAtUtc = _invisibleSinceUtc.Value.Add(VisibilityEvictionGrace); + + StartVisibilityGraceTask(); } + else + { + CancelVisibilityGraceTask(); + + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; + + ScheduledForDeletion = false; + + if (_charaHandler is not null && _charaHandler.Address != nint.Zero) + _ = EnsurePenumbraCollection(); + } + + var user = GetPrimaryUserData(); + Mediator.Publish(new EventMessage(new Event(PlayerName, user, nameof(PairHandlerAdapter), + EventSeverity.Informational, "User Visibility Changed, now: " + (_isVisible ? "Is Visible" : "Is not Visible")))); + Mediator.Publish(new RefreshUiMessage()); + Mediator.Publish(new VisibilityChange()); } } @@ -918,6 +938,46 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } } + private void CancelVisibilityGraceTask() + { + lock (_visibilityGraceGate) + { + _visibilityGraceCts?.CancelDispose(); + _visibilityGraceCts = null; + } + } + + private void StartVisibilityGraceTask() + { + CancellationToken token; + lock (_visibilityGraceGate) + { + _visibilityGraceCts = _visibilityGraceCts?.CancelRecreate() ?? new CancellationTokenSource(); + token = _visibilityGraceCts.Token; + } + + _visibilityGraceTask = Task.Run(async () => + { + try + { + await Task.Delay(VisibilityEvictionGrace, token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + if (IsVisible) return; + + ScheduledForDeletion = true; + ResetPenumbraCollection(reason: "VisibilityLostTimeout"); + } + catch (OperationCanceledException) + { + // operation cancelled, do nothing + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Visibility grace task failed for {handler}", GetLogIdentifier()); + } + }, CancellationToken.None); + } + protected override void Dispose(bool disposing) { base.Dispose(disposing); @@ -936,7 +996,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _downloadCancellationTokenSource = null; _downloadManager.Dispose(); _charaHandler?.Dispose(); + CancelVisibilityGraceTask(); _charaHandler = null; + _invisibleSinceUtc = null; + _visibilityEvictionDueAtUtc = null; if (!string.IsNullOrEmpty(name)) { @@ -1265,6 +1328,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } private Task? _pairDownloadTask; + private Task _visibilityGraceTask; private async Task DownloadAndApplyCharacterAsync(Guid applicationBase, CharacterData charaData, Dictionary> updatedData, bool updateModdedPaths, bool updateManip, Dictionary<(string GamePath, string? Hash), string>? cachedModdedPaths, CancellationToken downloadToken) diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs index f490804..ec05ee7 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerRegistry.cs @@ -11,7 +11,9 @@ public sealed class PairHandlerRegistry : IDisposable { private readonly object _gate = new(); private readonly object _pendingGate = new(); + private readonly object _visibilityGate = new(); private readonly Dictionary _entriesByIdent = new(StringComparer.Ordinal); + private readonly Dictionary _pendingInvisibleEvictions = new(StringComparer.Ordinal); private readonly Dictionary _entriesByHandler = new(ReferenceEqualityComparer.Instance); private readonly IPairHandlerAdapterFactory _handlerFactory; @@ -144,6 +146,37 @@ public sealed class PairHandlerRegistry : IDisposable return PairOperationResult.Ok(registration.PairIdent); } + private PairOperationResult CancelAllInvisibleEvictions() + { + List snapshot; + lock (_visibilityGate) + { + snapshot = [.. _pendingInvisibleEvictions.Values]; + _pendingInvisibleEvictions.Clear(); + } + + List? errors = null; + + foreach (var cts in snapshot) + { + try { cts.Cancel(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Cancel: {ex.Message}"); + } + + try { cts.Dispose(); } + catch (Exception ex) + { + (errors ??= new List()).Add($"Dispose: {ex.Message}"); + } + } + + return errors is null + ? PairOperationResult.Ok() + : PairOperationResult.Fail($"CancelAllInvisibleEvictions had error(s): {string.Join(" | ", errors)}"); + } + public PairOperationResult ApplyCharacterData(PairRegistration registration, OnlineUserCharaDataDto dto) { if (registration.CharacterIdent is null) @@ -300,6 +333,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } @@ -332,6 +366,7 @@ public sealed class PairHandlerRegistry : IDisposable lock (_gate) { handlers = _entriesByHandler.Keys.ToList(); + CancelAllInvisibleEvictions(); _entriesByIdent.Clear(); _entriesByHandler.Clear(); } diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 4ce64ac..c30d5fa 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -1463,7 +1463,10 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairPropertyRow("Has Handler", FormatBool(debugInfo.HasHandler)); DrawPairPropertyRow("Handler Initialized", FormatBool(debugInfo.HandlerInitialized)); DrawPairPropertyRow("Handler Visible", FormatBool(debugInfo.HandlerVisible)); + DrawPairPropertyRow("Last Time person rendered in", FormatTimestamp(debugInfo.InvisibleSinceUtc)); + DrawPairPropertyRow("Handler Timer Temp Collection removal", FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)); DrawPairPropertyRow("Handler Scheduled For Deletion", FormatBool(debugInfo.HandlerScheduledForDeletion)); + DrawPairPropertyRow("Note", pair.GetNote() ?? "(none)"); ImGui.EndTable(); } @@ -1698,6 +1701,19 @@ public class SettingsUi : WindowMediatorSubscriberBase return value is null ? "n/a" : value.Value.ToLocalTime().ToString("G", CultureInfo.CurrentCulture); } + private static string? FormatCountdown(double? remainingSeconds) + { + if (!remainingSeconds.HasValue) + return "No"; + + var secs = Math.Max(0, remainingSeconds.Value); + var t = TimeSpan.FromSeconds(secs); + + return t.TotalHours >= 1 + ? $"{(int)t.TotalHours:00}:{t.Minutes:00}:{t.Seconds:00}" + : $"{(int)t.TotalMinutes:00}:{t.Seconds:00}"; + } + private static string FormatBytes(long value) => value < 0 ? "n/a" : UiSharedService.ByteToString(value); private static string FormatCharacterId(uint id) => id == uint.MaxValue ? "n/a" : $"{id} (0x{id:X8})"; -- 2.49.1 From 1c4c73327fba4d3e537487671e1900e49083e27e Mon Sep 17 00:00:00 2001 From: cake Date: Sun, 21 Dec 2025 02:50:03 +0100 Subject: [PATCH 139/140] Added online filter like visible, seperated them for now. need to refactor. --- .../Configurations/LightlessConfig.cs | 3 +- LightlessSync/UI/CompactUI.cs | 27 +++++++-- LightlessSync/UI/Components/DrawFolderTag.cs | 60 ++++++++++++++++++- LightlessSync/UI/Models/OnlinePairSortMode.cs | 7 +++ .../UI/Models/VisiblePairSortMode.cs | 11 ++-- 5 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 LightlessSync/UI/Models/OnlinePairSortMode.cs diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index c16b380..9b4055b 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -49,7 +49,8 @@ public class LightlessConfig : ILightlessConfiguration public int DownloadSpeedLimitInBytes { get; set; } = 0; public DownloadSpeeds DownloadSpeedType { get; set; } = DownloadSpeeds.MBps; public bool PreferNotesOverNamesForVisible { get; set; } = false; - public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Default; + public VisiblePairSortMode VisiblePairSortMode { get; set; } = VisiblePairSortMode.Alphabetical; + public OnlinePairSortMode OnlinePairSortMode { get; set; } = OnlinePairSortMode.Alphabetical; public float ProfileDelay { get; set; } = 1.5f; public bool ProfilePopoutRight { get; set; } = false; public bool ProfilesAllowNsfw { get; set; } = false; diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index cd758f5..b1195b4 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -843,12 +843,16 @@ public class CompactUi : WindowMediatorSubscriberBase //Filter of not grouped/foldered and offline pairs var allOnlineNotTaggedPairs = SortEntries(allEntries.Where(FilterNotTaggedUsers)); - var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); - - if (allOnlineNotTaggedPairs.Count > 0) - { + if (allOnlineNotTaggedPairs.Count > 0 && _configService.Current.ShowOfflineUsersSeparately) { + var filteredOnlineEntries = SortOnlineEntries(filteredEntries.Where(e => FilterNotTaggedUsers(e) && FilterOnlineOrPausedSelf(e))); drawFolders.Add(_drawEntityFactory.CreateTagFolder( - _configService.Current.ShowOfflineUsersSeparately ? TagHandler.CustomOnlineTag : TagHandler.CustomAllTag, + TagHandler.CustomOnlineTag, + filteredOnlineEntries, + allOnlineNotTaggedPairs)); + } else if (allOnlineNotTaggedPairs.Count > 0 && !_configService.Current.ShowOfflineUsersSeparately) { + var onlineNotTaggedPairs = SortEntries(filteredEntries.Where(FilterNotTaggedUsers)); + drawFolders.Add(_drawEntityFactory.CreateTagFolder( + TagHandler.CustomAllTag, onlineNotTaggedPairs, allOnlineNotTaggedPairs)); } @@ -885,7 +889,7 @@ public class CompactUi : WindowMediatorSubscriberBase } } - private bool PassesFilter(PairUiEntry entry, string filter) + private static bool PassesFilter(PairUiEntry entry, string filter) { if (string.IsNullOrEmpty(filter)) return true; @@ -946,6 +950,17 @@ public class CompactUi : WindowMediatorSubscriberBase }; } + private ImmutableList SortOnlineEntries(IEnumerable entries) + { + var entryList = entries.ToList(); + return _configService.Current.OnlinePairSortMode switch + { + OnlinePairSortMode.Alphabetical => [.. entryList.OrderBy(e => AlphabeticalSortKey(e), StringComparer.OrdinalIgnoreCase)], + OnlinePairSortMode.PreferredDirectPairs => SortVisibleByPreferred(entryList), + _ => SortEntries(entryList), + }; + } + private ImmutableList SortVisibleByMetric(IEnumerable entries, Func selector) { return [.. entries diff --git a/LightlessSync/UI/Components/DrawFolderTag.cs b/LightlessSync/UI/Components/DrawFolderTag.cs index dcba0d4..b91617a 100644 --- a/LightlessSync/UI/Components/DrawFolderTag.cs +++ b/LightlessSync/UI/Components/DrawFolderTag.cs @@ -169,11 +169,16 @@ public class DrawFolderTag : DrawFolderBase protected override float DrawRightSide(float currentRightSideX) { - if (_id == TagHandler.CustomVisibleTag) + if (string.Equals(_id, TagHandler.CustomVisibleTag, StringComparison.Ordinal)) { return DrawVisibleFilter(currentRightSideX); } + if (string.Equals(_id, TagHandler.CustomOnlineTag, StringComparison.Ordinal)) + { + return DrawOnlineFilter(currentRightSideX); + } + if (!RenderPause) { return currentRightSideX; @@ -254,7 +259,7 @@ public class DrawFolderTag : DrawFolderBase foreach (VisiblePairSortMode mode in Enum.GetValues()) { var selected = _configService.Current.VisiblePairSortMode == mode; - if (ImGui.MenuItem(GetSortLabel(mode), string.Empty, selected)) + if (ImGui.MenuItem(GetSortVisibleLabel(mode), string.Empty, selected)) { if (!selected) { @@ -273,7 +278,49 @@ public class DrawFolderTag : DrawFolderBase return buttonStart - spacingX; } - private static string GetSortLabel(VisiblePairSortMode mode) => mode switch + private float DrawOnlineFilter(float currentRightSideX) + { + var buttonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Filter); + var spacingX = ImGui.GetStyle().ItemSpacing.X; + var buttonStart = currentRightSideX - buttonSize.X; + + ImGui.SameLine(buttonStart); + if (_uiSharedService.IconButton(FontAwesomeIcon.Filter)) + { + SuppressNextRowToggle(); + ImGui.OpenPopup($"online-filter-{_id}"); + } + + UiSharedService.AttachToolTip("Adjust how online pairs are ordered."); + + if (ImGui.BeginPopup($"online-filter-{_id}")) + { + ImGui.TextUnformatted("Online Pair Ordering"); + ImGui.Separator(); + + foreach (OnlinePairSortMode mode in Enum.GetValues()) + { + var selected = _configService.Current.OnlinePairSortMode == mode; + if (ImGui.MenuItem(GetSortOnlineLabel(mode), string.Empty, selected)) + { + if (!selected) + { + _configService.Current.OnlinePairSortMode = mode; + _configService.Save(); + _mediator.Publish(new RefreshUiMessage()); + } + + ImGui.CloseCurrentPopup(); + } + } + + ImGui.EndPopup(); + } + + return buttonStart - spacingX; + } + + private static string GetSortVisibleLabel(VisiblePairSortMode mode) => mode switch { VisiblePairSortMode.Alphabetical => "Alphabetical", VisiblePairSortMode.VramUsage => "VRAM usage (descending)", @@ -282,4 +329,11 @@ public class DrawFolderTag : DrawFolderBase VisiblePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", _ => "Default", }; + + private static string GetSortOnlineLabel(OnlinePairSortMode mode) => mode switch + { + OnlinePairSortMode.Alphabetical => "Alphabetical", + OnlinePairSortMode.PreferredDirectPairs => "Preferred permissions & Direct pairs", + _ => "Default", + }; } \ No newline at end of file diff --git a/LightlessSync/UI/Models/OnlinePairSortMode.cs b/LightlessSync/UI/Models/OnlinePairSortMode.cs new file mode 100644 index 0000000..ff85b9c --- /dev/null +++ b/LightlessSync/UI/Models/OnlinePairSortMode.cs @@ -0,0 +1,7 @@ +namespace LightlessSync.UI.Models; + +public enum OnlinePairSortMode +{ + Alphabetical = 0, + PreferredDirectPairs = 1, +} diff --git a/LightlessSync/UI/Models/VisiblePairSortMode.cs b/LightlessSync/UI/Models/VisiblePairSortMode.cs index fcb1d65..ec133b9 100644 --- a/LightlessSync/UI/Models/VisiblePairSortMode.cs +++ b/LightlessSync/UI/Models/VisiblePairSortMode.cs @@ -2,10 +2,9 @@ namespace LightlessSync.UI.Models; public enum VisiblePairSortMode { - Default = 0, - Alphabetical = 1, - VramUsage = 2, - EffectiveVramUsage = 3, - TriangleCount = 4, - PreferredDirectPairs = 5, + Alphabetical = 0, + VramUsage = 1, + EffectiveVramUsage = 2, + TriangleCount = 3, + PreferredDirectPairs = 4, } -- 2.49.1 From ad0254a81294dfb76d17b7bdda3e66dd872e2a87 Mon Sep 17 00:00:00 2001 From: azyges Date: Sun, 21 Dec 2025 20:37:23 +0900 Subject: [PATCH 140/140] meow moodles ipc meow --- LightlessSync/FileCache/CacheMonitor.cs | 23 ++++-- .../FileCache/TransientResourceManager.cs | 3 + LightlessSync/Interop/Ipc/IpcCallerMoodles.cs | 13 ++-- .../PlayerData/Factories/PlayerDataFactory.cs | 76 ++++++++++++++----- 4 files changed, 83 insertions(+), 32 deletions(-) diff --git a/LightlessSync/FileCache/CacheMonitor.cs b/LightlessSync/FileCache/CacheMonitor.cs index 83c3b96..fde9b6d 100644 --- a/LightlessSync/FileCache/CacheMonitor.cs +++ b/LightlessSync/FileCache/CacheMonitor.cs @@ -6,6 +6,7 @@ using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Collections.Immutable; +using System.IO; namespace LightlessSync.FileCache; @@ -21,6 +22,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase private CancellationTokenSource _scanCancellationTokenSource = new(); private readonly CancellationTokenSource _periodicCalculationTokenSource = new(); public static readonly IImmutableList AllowedFileExtensions = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".pbd", ".scd", ".skp", ".shpk", ".kdb"]; + private static readonly HashSet AllowedFileExtensionSet = new(AllowedFileExtensions, StringComparer.OrdinalIgnoreCase); public CacheMonitor(ILogger logger, IpcManager ipcManager, LightlessConfigService configService, FileCacheManager fileDbManager, LightlessMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil, @@ -163,7 +165,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase { Logger.LogTrace("Lightless FSW: FileChanged: {change} => {path}", e.ChangeType, e.FullPath); - if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return; + if (!HasAllowedExtension(e.FullPath)) return; lock (_watcherChanges) { @@ -207,7 +209,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase private void Fs_Changed(object sender, FileSystemEventArgs e) { if (Directory.Exists(e.FullPath)) return; - if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return; + if (!HasAllowedExtension(e.FullPath)) return; if (e.ChangeType is not (WatcherChangeTypes.Changed or WatcherChangeTypes.Deleted or WatcherChangeTypes.Created)) return; @@ -231,7 +233,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase { foreach (var file in directoryFiles) { - if (!AllowedFileExtensions.Any(ext => file.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) continue; + if (!HasAllowedExtension(file)) continue; var oldPath = file.Replace(e.FullPath, e.OldFullPath, StringComparison.OrdinalIgnoreCase); _watcherChanges.Remove(oldPath); @@ -243,7 +245,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase } else { - if (!AllowedFileExtensions.Any(ext => e.FullPath.EndsWith(ext, StringComparison.OrdinalIgnoreCase))) return; + if (!HasAllowedExtension(e.FullPath)) return; lock (_watcherChanges) { @@ -263,6 +265,17 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase public FileSystemWatcher? PenumbraWatcher { get; private set; } public FileSystemWatcher? LightlessWatcher { get; private set; } + private static bool HasAllowedExtension(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + var extension = Path.GetExtension(path); + return !string.IsNullOrEmpty(extension) && AllowedFileExtensionSet.Contains(extension); + } + private async Task LightlessWatcherExecution() { _lightlessFswCts = _lightlessFswCts.CancelRecreate(); @@ -606,7 +619,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase [ .. Directory.GetFiles(folder, "*.*", SearchOption.AllDirectories) .AsParallel() - .Where(f => AllowedFileExtensions.Any(e => f.EndsWith(e, StringComparison.OrdinalIgnoreCase)) + .Where(f => HasAllowedExtension(f) && !f.Contains(@"\bg\", StringComparison.OrdinalIgnoreCase) && !f.Contains(@"\bgcommon\", StringComparison.OrdinalIgnoreCase) && !f.Contains(@"\ui\", StringComparison.OrdinalIgnoreCase)), diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 6d77a97..2ef8f22 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -372,6 +372,9 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) { + if (descriptor.IsInGpose) + return; + if (!TryResolveObjectKind(descriptor, out var resolvedKind)) return; diff --git a/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs b/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs index e8b1b76..1bbbfda 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerMoodles.cs @@ -1,5 +1,4 @@ -using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Plugin; +using Dalamud.Plugin; using Dalamud.Plugin.Ipc; using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Services; @@ -13,7 +12,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase private static readonly IpcServiceDescriptor MoodlesDescriptor = new("Moodles", "Moodles", new Version(0, 0, 0, 0)); private readonly ICallGateSubscriber _moodlesApiVersion; - private readonly ICallGateSubscriber _moodlesOnChange; + private readonly ICallGateSubscriber _moodlesOnChange; private readonly ICallGateSubscriber _moodlesGetStatus; private readonly ICallGateSubscriber _moodlesSetStatus; private readonly ICallGateSubscriber _moodlesRevertStatus; @@ -29,7 +28,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase _lightlessMediator = lightlessMediator; _moodlesApiVersion = pi.GetIpcSubscriber("Moodles.Version"); - _moodlesOnChange = pi.GetIpcSubscriber("Moodles.StatusManagerModified"); + _moodlesOnChange = pi.GetIpcSubscriber("Moodles.StatusManagerModified"); _moodlesGetStatus = pi.GetIpcSubscriber("Moodles.GetStatusManagerByPtrV2"); _moodlesSetStatus = pi.GetIpcSubscriber("Moodles.SetStatusManagerByPtrV2"); _moodlesRevertStatus = pi.GetIpcSubscriber("Moodles.ClearStatusManagerByPtrV2"); @@ -39,9 +38,9 @@ public sealed class IpcCallerMoodles : IpcServiceBase CheckAPI(); } - private void OnMoodlesChange(IPlayerCharacter character) + private void OnMoodlesChange(nint address) { - _lightlessMediator.Publish(new MoodlesMessage(character.Address)); + _lightlessMediator.Publish(new MoodlesMessage(address)); } protected override void Dispose(bool disposing) @@ -107,7 +106,7 @@ public sealed class IpcCallerMoodles : IpcServiceBase try { - return _moodlesApiVersion.InvokeFunc() == 3 + return _moodlesApiVersion.InvokeFunc() >= 4 ? IpcConnectionState.Available : IpcConnectionState.VersionMismatch; } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index f752051..39aa6c8 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -119,6 +119,7 @@ public class PlayerDataFactory CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new(); _logger.LogDebug("Building character data for {obj}", playerRelatedObject); + var logDebug = _logger.IsEnabled(LogLevel.Debug); // wait until chara is not drawing and present so nothing spontaneously explodes await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct).ConfigureAwait(false); @@ -132,11 +133,6 @@ public class PlayerDataFactory ct.ThrowIfCancellationRequested(); - Dictionary>? boneIndices = - objectKind != ObjectKind.Player - ? null - : await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false); - DateTime start = DateTime.UtcNow; // penumbra call, it's currently broken @@ -154,11 +150,21 @@ public class PlayerDataFactory ct.ThrowIfCancellationRequested(); - _logger.LogDebug("== Static Replacements =="); - foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase)) + if (logDebug) { - _logger.LogDebug("=> {repl}", replacement); - ct.ThrowIfCancellationRequested(); + _logger.LogDebug("== Static Replacements =="); + foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase)) + { + _logger.LogDebug("=> {repl}", replacement); + ct.ThrowIfCancellationRequested(); + } + } + else + { + foreach (var replacement in fragment.FileReplacements.Where(i => i.HasFileReplacement)) + { + ct.ThrowIfCancellationRequested(); + } } await _transientResourceManager.WaitForRecording(ct).ConfigureAwait(false); @@ -190,11 +196,21 @@ public class PlayerDataFactory var transientPaths = ManageSemiTransientData(objectKind); var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); - _logger.LogDebug("== Transient Replacements =="); - foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal)) + if (logDebug) { - _logger.LogDebug("=> {repl}", replacement); - fragment.FileReplacements.Add(replacement); + _logger.LogDebug("== Transient Replacements =="); + foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal)) + { + _logger.LogDebug("=> {repl}", replacement); + fragment.FileReplacements.Add(replacement); + } + } + else + { + foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key))) + { + fragment.FileReplacements.Add(replacement); + } } // clean up all semi transient resources that don't have any file replacement (aka null resolve) @@ -252,11 +268,26 @@ public class PlayerDataFactory ct.ThrowIfCancellationRequested(); + Dictionary>? boneIndices = null; + var hasPapFiles = false; + if (objectKind == ObjectKind.Player) + { + hasPapFiles = fragment.FileReplacements.Any(f => + !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)); + if (hasPapFiles) + { + boneIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false); + } + } + if (objectKind == ObjectKind.Player) { try { - await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false); + if (hasPapFiles) + { + await VerifyPlayerAnimationBones(boneIndices, (fragment as CharacterDataFragmentPlayer)!, ct).ConfigureAwait(false); + } } catch (OperationCanceledException e) { @@ -278,12 +309,16 @@ public class PlayerDataFactory { if (boneIndices == null) return; - foreach (var kvp in boneIndices) + if (_logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value)); + foreach (var kvp in boneIndices) + { + _logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value)); + } } - if (boneIndices.All(u => u.Value.Count == 0)) return; + var maxPlayerBoneIndex = boneIndices.SelectMany(kvp => kvp.Value).DefaultIfEmpty().Max(); + if (maxPlayerBoneIndex <= 0) return; int noValidationFailed = 0; foreach (var file in fragment.FileReplacements.Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList()) @@ -303,12 +338,13 @@ public class PlayerDataFactory _logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count); - foreach (var boneCount in skeletonIndices.Select(k => k).ToList()) + foreach (var boneCount in skeletonIndices) { - if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max()) + var maxAnimationIndex = boneCount.Value.DefaultIfEmpty().Max(); + if (maxAnimationIndex > maxPlayerBoneIndex) { _logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})", - file.ResolvedPath, boneCount.Key, boneCount.Value.Max(), boneIndices.SelectMany(b => b.Value).Max()); + file.ResolvedPath, boneCount.Key, maxAnimationIndex, maxPlayerBoneIndex); validationFailed = true; break; } -- 2.49.1