From 96123d00a29fd065424ab3409171351a92d9663d Mon Sep 17 00:00:00 2001 From: azyges Date: Fri, 16 Jan 2026 11:00:58 +0900 Subject: [PATCH] sigma update --- .../FileCache/CompactorInterfaces.cs | 18 + .../FileCache/FileCompactor.cs | 88 +- LightlessCompactor/LightlessCompactor.csproj | 15 + .../Compactor/BatchFileFragService.cs | 0 .../Utils/FileSystemHelper.cs | 0 .../LightlessCompactorWorker.csproj | 19 + LightlessCompactorWorker/Program.cs | 270 +++ LightlessSync.sln | 28 + .../FileCache/ExternalCompactionExecutor.cs | 241 +++ LightlessSync/FileCache/FileCacheManager.cs | 34 + .../FileCache/PluginCompactorContext.cs | 20 + .../FileCache/TransientResourceManager.cs | 77 +- .../Interop/Ipc/IpcCallerPenumbra.cs | 10 +- .../Interop/Ipc/Penumbra/PenumbraResource.cs | 23 +- .../Interop/Ipc/Penumbra/PenumbraTexture.cs | 4 +- .../Configurations/ChatConfig.cs | 6 + .../Configurations/LightlessConfig.cs | 2 + .../Configurations/PlayerPerformanceConfig.cs | 5 +- LightlessSync/LightlessSync.csproj | 10 + .../Factories/FileDownloadManagerFactory.cs | 8 +- .../PlayerData/Factories/PlayerDataFactory.cs | 56 +- .../PlayerData/Pairs/IPairHandlerAdapter.cs | 79 +- .../PlayerData/Pairs/PairHandlerAdapter.cs | 165 +- LightlessSync/PlayerData/Pairs/PairLedger.cs | 15 +- LightlessSync/Plugin.cs | 7 +- .../ActorTracking/ActorObjectService.cs | 68 +- .../Services/Chat/ZoneChatService.cs | 477 +++++- LightlessSync/Services/DalamudUtilService.cs | 98 +- LightlessSync/Services/Mediator/Messages.cs | 6 + .../Services/ModelDecimation/MdlDecimator.cs | 598 ++++++- .../ModelDecimation/ModelDecimationService.cs | 27 +- .../TextureCompressionService.cs | 75 +- .../TextureDownscaleService.cs | 217 ++- LightlessSync/Services/UiService.cs | 74 +- .../FastQuadricMeshSimplification.cs | 78 + .../ThirdParty/MeshDecimator/Mesh.cs | 51 + LightlessSync/UI/CompactUI.cs | 128 +- LightlessSync/UI/Components/DrawFolderBase.cs | 7 +- .../UI/Components/DrawGroupedGroupFolder.cs | 48 +- LightlessSync/UI/Components/DrawUserPair.cs | 58 +- .../Components/OptimizationSettingsPanel.cs | 930 +++++++++++ .../UI/Components/OptimizationSummaryCard.cs | 789 +++++++++ LightlessSync/UI/DownloadUi.cs | 11 +- LightlessSync/UI/SettingsUi.cs | 656 +++----- LightlessSync/UI/Style/MainStyle.cs | 8 +- LightlessSync/UI/Style/Selune.cs | 22 +- LightlessSync/UI/ZoneChatUi.cs | 1455 +++++++++++++++-- LightlessSync/Utils/TaskRegistry.cs | 81 + .../WebAPI/Files/FileDownloadDeduplicator.cs | 48 + .../WebAPI/Files/FileDownloadManager.cs | 806 +++++++-- LightlessSync/packages.lock.json | 6 + 51 files changed, 6640 insertions(+), 1382 deletions(-) create mode 100644 LightlessCompactor/FileCache/CompactorInterfaces.cs rename {LightlessSync => LightlessCompactor}/FileCache/FileCompactor.cs (94%) create mode 100644 LightlessCompactor/LightlessCompactor.csproj rename {LightlessSync => LightlessCompactor}/Services/Compactor/BatchFileFragService.cs (100%) rename {LightlessSync => LightlessCompactor}/Utils/FileSystemHelper.cs (100%) create mode 100644 LightlessCompactorWorker/LightlessCompactorWorker.csproj create mode 100644 LightlessCompactorWorker/Program.cs create mode 100644 LightlessSync/FileCache/ExternalCompactionExecutor.cs create mode 100644 LightlessSync/FileCache/PluginCompactorContext.cs create mode 100644 LightlessSync/UI/Components/OptimizationSettingsPanel.cs create mode 100644 LightlessSync/UI/Components/OptimizationSummaryCard.cs create mode 100644 LightlessSync/Utils/TaskRegistry.cs create mode 100644 LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs diff --git a/LightlessCompactor/FileCache/CompactorInterfaces.cs b/LightlessCompactor/FileCache/CompactorInterfaces.cs new file mode 100644 index 0000000..59fc255 --- /dev/null +++ b/LightlessCompactor/FileCache/CompactorInterfaces.cs @@ -0,0 +1,18 @@ +namespace LightlessSync.FileCache; + +public interface ICompactorContext +{ + bool UseCompactor { get; } + string CacheFolder { get; } + bool IsWine { get; } +} + +public interface ICompactionExecutor +{ + bool TryCompact(string filePath); +} + +public sealed class NoopCompactionExecutor : ICompactionExecutor +{ + public bool TryCompact(string filePath) => false; +} diff --git a/LightlessSync/FileCache/FileCompactor.cs b/LightlessCompactor/FileCache/FileCompactor.cs similarity index 94% rename from LightlessSync/FileCache/FileCompactor.cs rename to LightlessCompactor/FileCache/FileCompactor.cs index 771f558..cc3b46c 100644 --- a/LightlessSync/FileCache/FileCompactor.cs +++ b/LightlessCompactor/FileCache/FileCompactor.cs @@ -1,6 +1,4 @@ -using LightlessSync.LightlessConfiguration; -using LightlessSync.Services; -using LightlessSync.Services.Compactor; +using LightlessSync.Services.Compactor; using Microsoft.Extensions.Logging; using Microsoft.Win32.SafeHandles; using System.Collections.Concurrent; @@ -20,8 +18,8 @@ public sealed partial class FileCompactor : IDisposable private readonly ConcurrentDictionary _pendingCompactions; private readonly ILogger _logger; - private readonly LightlessConfigService _lightlessConfigService; - private readonly DalamudUtilService _dalamudUtilService; + private readonly ICompactorContext _context; + private readonly ICompactionExecutor _compactionExecutor; private readonly Channel _compactionQueue; private readonly CancellationTokenSource _compactionCts = new(); @@ -59,12 +57,12 @@ public sealed partial class FileCompactor : IDisposable XPRESS16K = 3 } - public FileCompactor(ILogger logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) + public FileCompactor(ILogger logger, ICompactorContext context, ICompactionExecutor compactionExecutor) { _pendingCompactions = new(StringComparer.OrdinalIgnoreCase); - _logger = logger; - _lightlessConfigService = lightlessConfigService; - _dalamudUtilService = dalamudUtilService; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _compactionExecutor = compactionExecutor ?? throw new ArgumentNullException(nameof(compactionExecutor)); _isWindows = OperatingSystem.IsWindows(); _compactionQueue = Channel.CreateUnbounded(new UnboundedChannelOptions @@ -94,7 +92,7 @@ public sealed partial class FileCompactor : IDisposable //Uses an batching service for the filefrag command on Linux _fragBatch = new BatchFilefragService( - useShell: _dalamudUtilService.IsWine, + useShell: _context.IsWine, log: _logger, batchSize: 64, flushMs: 25, @@ -118,7 +116,7 @@ public sealed partial class FileCompactor : IDisposable try { - var folder = _lightlessConfigService.Current.CacheFolder; + var folder = _context.CacheFolder; if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) { if (_logger.IsEnabled(LogLevel.Warning)) @@ -127,7 +125,7 @@ public sealed partial class FileCompactor : IDisposable return; } - var files = Directory.EnumerateFiles(folder).ToArray(); + var files = Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToArray(); var total = files.Length; Progress = $"0/{total}"; if (total == 0) return; @@ -155,7 +153,7 @@ public sealed partial class FileCompactor : IDisposable { if (compress) { - if (_lightlessConfigService.Current.UseCompactor) + if (_context.UseCompactor) CompactFile(file, workerId); } else @@ -221,19 +219,52 @@ public sealed partial class FileCompactor : IDisposable await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); - if (_lightlessConfigService.Current.UseCompactor) + if (_context.UseCompactor) EnqueueCompaction(filePath); } + /// + /// Notify the compactor that a file was written directly (streamed) so it can enqueue compaction. + /// + public void NotifyFileWritten(string filePath) + { + EnqueueCompaction(filePath); + } + + public bool TryCompactFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + return false; + + if (!_context.UseCompactor || !File.Exists(filePath)) + return false; + + try + { + CompactFile(filePath, workerId: -1); + return true; + } + catch (IOException ioEx) + { + _logger.LogDebug(ioEx, "File being read/written, skipping file: {file}", filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error compacting file: {file}", filePath); + } + + return false; + } + /// /// 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); + var fsType = GetFilesystemType(fileInfo.FullName, _context.IsWine); - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { (bool flowControl, long value) = GetFileSizeNTFS(fileInfo); if (!flowControl) @@ -290,7 +321,7 @@ public sealed partial class FileCompactor : IDisposable { try { - var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); + var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _context.IsWine); if (blockSize <= 0) throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}"); @@ -330,7 +361,7 @@ public sealed partial class FileCompactor : IDisposable return; } - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(filePath, _context.IsWine); var oldSize = fi.Length; int blockSize = (int)(GetFileSizeOnDisk(fi) / 512); @@ -346,7 +377,7 @@ public sealed partial class FileCompactor : IDisposable return; } - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { if (!IsWOFCompactedFile(filePath)) { @@ -402,9 +433,9 @@ public sealed partial class FileCompactor : IDisposable private void DecompressFile(string filePath, int workerId) { _logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath); - var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); + var fsType = GetFilesystemType(filePath, _context.IsWine); - if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) + if (fsType == FilesystemType.NTFS && !_context.IsWine) { try { @@ -448,7 +479,7 @@ public sealed partial class FileCompactor : IDisposable { try { - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; var opts = GetMountOptionsForPath(linuxPath); @@ -961,7 +992,7 @@ public sealed partial class FileCompactor : IDisposable if (finished != bothTasks) return KillProcess(proc, outTask, errTask, token); - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; if (!isWine) { try { proc.WaitForExit(); } catch { /* ignore quirks */ } @@ -1005,7 +1036,7 @@ public sealed partial class FileCompactor : IDisposable if (string.IsNullOrWhiteSpace(filePath)) return; - if (!_lightlessConfigService.Current.UseCompactor) + if (!_context.UseCompactor) return; if (!File.Exists(filePath)) @@ -1017,7 +1048,7 @@ public sealed partial class FileCompactor : IDisposable bool enqueued = false; try { - bool isWine = _dalamudUtilService?.IsWine ?? false; + bool isWine = _context.IsWine; var fsType = GetFilesystemType(filePath, isWine); // If under Wine, we should skip NTFS because its not Windows but might return NTFS. @@ -1070,8 +1101,11 @@ public sealed partial class FileCompactor : IDisposable try { - if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath)) - CompactFile(filePath, workerId); + if (_context.UseCompactor && File.Exists(filePath)) + { + if (!_compactionExecutor.TryCompact(filePath)) + CompactFile(filePath, workerId); + } } finally { diff --git a/LightlessCompactor/LightlessCompactor.csproj b/LightlessCompactor/LightlessCompactor.csproj new file mode 100644 index 0000000..419cd5c --- /dev/null +++ b/LightlessCompactor/LightlessCompactor.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + latest + enable + enable + true + + + + + + + diff --git a/LightlessSync/Services/Compactor/BatchFileFragService.cs b/LightlessCompactor/Services/Compactor/BatchFileFragService.cs similarity index 100% rename from LightlessSync/Services/Compactor/BatchFileFragService.cs rename to LightlessCompactor/Services/Compactor/BatchFileFragService.cs diff --git a/LightlessSync/Utils/FileSystemHelper.cs b/LightlessCompactor/Utils/FileSystemHelper.cs similarity index 100% rename from LightlessSync/Utils/FileSystemHelper.cs rename to LightlessCompactor/Utils/FileSystemHelper.cs diff --git a/LightlessCompactorWorker/LightlessCompactorWorker.csproj b/LightlessCompactorWorker/LightlessCompactorWorker.csproj new file mode 100644 index 0000000..e943619 --- /dev/null +++ b/LightlessCompactorWorker/LightlessCompactorWorker.csproj @@ -0,0 +1,19 @@ + + + + WinExe + net10.0 + latest + enable + enable + + + + + + + + + + + diff --git a/LightlessCompactorWorker/Program.cs b/LightlessCompactorWorker/Program.cs new file mode 100644 index 0000000..26f09d1 --- /dev/null +++ b/LightlessCompactorWorker/Program.cs @@ -0,0 +1,270 @@ +using LightlessSync.FileCache; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; + +internal sealed class WorkerCompactorContext : ICompactorContext +{ + public WorkerCompactorContext(string cacheFolder, bool isWine) + { + CacheFolder = cacheFolder; + IsWine = isWine; + } + + public bool UseCompactor => true; + public string CacheFolder { get; } + public bool IsWine { get; } +} + +internal sealed class WorkerOptions +{ + public string? FilePath { get; init; } + public bool IsWine { get; init; } + public string CacheFolder { get; init; } = string.Empty; + public LogLevel LogLevel { get; init; } = LogLevel.Information; + public string PipeName { get; init; } = "LightlessCompactor"; + public int? ParentProcessId { get; init; } +} + +internal static class Program +{ + public static async Task Main(string[] args) + { + var options = ParseOptions(args, out var error); + if (options is null) + { + Console.Error.WriteLine(error ?? "Invalid arguments."); + Console.Error.WriteLine("Usage: LightlessCompactorWorker --file [--wine] [--cache-folder ] [--verbose]"); + Console.Error.WriteLine(" or: LightlessCompactorWorker --pipe [--wine] [--parent ] [--verbose]"); + return 2; + } + + TrySetLowPriority(); + + using var loggerFactory = LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(options.LogLevel); + builder.AddSimpleConsole(o => + { + o.SingleLine = true; + o.TimestampFormat = "HH:mm:ss.fff "; + }); + }); + + var logger = loggerFactory.CreateLogger(); + var context = new WorkerCompactorContext(options.CacheFolder, options.IsWine); + + using var compactor = new FileCompactor(logger, context, new NoopCompactionExecutor()); + + if (!string.IsNullOrWhiteSpace(options.FilePath)) + { + var success = compactor.TryCompactFile(options.FilePath!); + return success ? 0 : 1; + } + + var serverLogger = loggerFactory.CreateLogger("CompactorWorker"); + return await RunServerAsync(compactor, options, serverLogger).ConfigureAwait(false); + } + + private static async Task RunServerAsync(FileCompactor compactor, WorkerOptions options, ILogger serverLogger) + { + using var cts = new CancellationTokenSource(); + var token = cts.Token; + + if (options.ParentProcessId.HasValue) + { + _ = Task.Run(() => MonitorParent(options.ParentProcessId.Value, cts)); + } + + serverLogger.LogInformation("Compactor worker listening on pipe {pipe}", options.PipeName); + + try + { + while (!token.IsCancellationRequested) + { + var server = new NamedPipeServerStream( + options.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + try + { + await server.WaitForConnectionAsync(token).ConfigureAwait(false); + } + catch + { + server.Dispose(); + throw; + } + + _ = Task.Run(() => HandleClientAsync(server, compactor, cts)); + } + } + catch (OperationCanceledException) + { + // shutdown requested + } + catch (Exception ex) + { + serverLogger.LogWarning(ex, "Compactor worker terminated unexpectedly."); + return 1; + } + + return 0; + } + + private static async Task HandleClientAsync(NamedPipeServerStream pipe, FileCompactor compactor, CancellationTokenSource shutdownCts) + { + await using var _ = pipe; + using var reader = new StreamReader(pipe); + using var writer = new StreamWriter(pipe) { AutoFlush = true }; + + var line = await reader.ReadLineAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(line)) + return; + + CompactorRequest? request = null; + try + { + request = JsonSerializer.Deserialize(line); + } + catch + { + // ignore + } + + CompactorResponse response; + if (request is null) + { + response = new CompactorResponse { Success = false, Error = "Invalid request." }; + } + else if (string.Equals(request.Type, "shutdown", StringComparison.OrdinalIgnoreCase)) + { + shutdownCts.Cancel(); + response = new CompactorResponse { Success = true }; + } + else if (string.Equals(request.Type, "compact", StringComparison.OrdinalIgnoreCase)) + { + var success = compactor.TryCompactFile(request.Path ?? string.Empty); + response = new CompactorResponse { Success = success }; + } + else + { + response = new CompactorResponse { Success = false, Error = "Unknown request type." }; + } + + await writer.WriteLineAsync(JsonSerializer.Serialize(response)).ConfigureAwait(false); + } + + private static void MonitorParent(int parentPid, CancellationTokenSource shutdownCts) + { + try + { + var parent = Process.GetProcessById(parentPid); + parent.WaitForExit(); + } + catch + { + // parent missing + } + finally + { + shutdownCts.Cancel(); + } + } + + private static WorkerOptions? ParseOptions(string[] args, out string? error) + { + string? filePath = null; + bool isWine = false; + string cacheFolder = string.Empty; + var logLevel = LogLevel.Information; + string pipeName = "LightlessCompactor"; + int? parentPid = null; + + for (int i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg) + { + case "--file": + if (i + 1 >= args.Length) + { + error = "Missing value for --file."; + return null; + } + filePath = args[++i]; + break; + case "--cache-folder": + if (i + 1 >= args.Length) + { + error = "Missing value for --cache-folder."; + return null; + } + cacheFolder = args[++i]; + break; + case "--pipe": + if (i + 1 >= args.Length) + { + error = "Missing value for --pipe."; + return null; + } + pipeName = args[++i]; + break; + case "--parent": + if (i + 1 >= args.Length || !int.TryParse(args[++i], out var pid)) + { + error = "Invalid value for --parent."; + return null; + } + parentPid = pid; + break; + case "--wine": + isWine = true; + break; + case "--verbose": + logLevel = LogLevel.Trace; + break; + } + } + + error = null; + return new WorkerOptions + { + FilePath = filePath, + IsWine = isWine, + CacheFolder = cacheFolder, + LogLevel = logLevel, + PipeName = pipeName, + ParentProcessId = parentPid + }; + } + + private static void TrySetLowPriority() + { + try + { + if (OperatingSystem.IsWindows()) + Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // ignore + } + } + + private sealed class CompactorRequest + { + public string Type { get; init; } = "compact"; + public string? Path { get; init; } + } + + private sealed class CompactorResponse + { + public bool Success { get; init; } + public string? Error { get; init; } + } +} diff --git a/LightlessSync.sln b/LightlessSync.sln index 55bddfd..f69eb4b 100644 --- a/LightlessSync.sln +++ b/LightlessSync.sln @@ -22,6 +22,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterG EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactor", "LightlessCompactor\LightlessCompactor.csproj", "{01F31917-9F1E-426D-BDAE-17268CBF9523}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactorWorker", "LightlessCompactorWorker\LightlessCompactorWorker.csproj", "{72BE3664-CD0E-4DA4-B040-91338A2798E0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -116,6 +120,30 @@ Global {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 + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.ActiveCfg = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.Build.0 = Debug|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.Build.0 = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.Build.0 = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.ActiveCfg = Release|Any CPU + {01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.Build.0 = Debug|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.Build.0 = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.ActiveCfg = Release|Any CPU + {72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/LightlessSync/FileCache/ExternalCompactionExecutor.cs b/LightlessSync/FileCache/ExternalCompactionExecutor.cs new file mode 100644 index 0000000..85c5a64 --- /dev/null +++ b/LightlessSync/FileCache/ExternalCompactionExecutor.cs @@ -0,0 +1,241 @@ +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.IO.Pipes; +using System.Text.Json; + +namespace LightlessSync.FileCache; + +internal sealed class ExternalCompactionExecutor : ICompactionExecutor, IDisposable +{ + private readonly ILogger _logger; + private readonly ICompactorContext _context; + private readonly TimeSpan _timeout = TimeSpan.FromMinutes(5); + private readonly string _pipeName; + private Process? _workerProcess; + private bool _disposed; + private readonly object _sync = new(); + + public ExternalCompactionExecutor(ILogger logger, ICompactorContext context) + { + _logger = logger; + _context = context; + _pipeName = $"LightlessCompactor-{Environment.ProcessId}"; + } + + public bool TryCompact(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + return false; + + if (!EnsureWorkerRunning()) + return false; + + try + { + var request = new CompactorRequest + { + Type = "compact", + Path = filePath + }; + + return SendRequest(request, out var response) && response?.Success == true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "External compactor failed for {file}", filePath); + return false; + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + try + { + SendRequest(new CompactorRequest { Type = "shutdown" }, out _); + } + catch + { + // ignore + } + + lock (_sync) + { + if (_workerProcess is null) + return; + + TryKill(_workerProcess); + _workerProcess.Dispose(); + _workerProcess = null; + } + } + + private bool EnsureWorkerRunning() + { + lock (_sync) + { + if (_workerProcess is { HasExited: false }) + return true; + + _workerProcess?.Dispose(); + _workerProcess = null; + + var workerPath = ResolveWorkerPath(); + if (string.IsNullOrEmpty(workerPath)) + return false; + + var args = BuildArguments(); + var startInfo = new ProcessStartInfo + { + FileName = workerPath, + Arguments = args, + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + return false; + + TrySetLowPriority(process); + _ = DrainAsync(process.StandardOutput, "stdout"); + _ = DrainAsync(process.StandardError, "stderr"); + + _workerProcess = process; + return true; + } + } + + private bool SendRequest(CompactorRequest request, out CompactorResponse? response) + { + response = null; + using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); + + try + { + pipe.Connect((int)_timeout.TotalMilliseconds); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Compactor pipe connection failed."); + return false; + } + + using var writer = new StreamWriter(pipe) { AutoFlush = true }; + using var reader = new StreamReader(pipe); + + var payload = JsonSerializer.Serialize(request); + writer.WriteLine(payload); + + var readTask = reader.ReadLineAsync(); + if (!readTask.Wait(_timeout)) + { + _logger.LogWarning("Compactor pipe timed out waiting for response."); + return false; + } + + var line = readTask.Result; + if (string.IsNullOrWhiteSpace(line)) + return false; + + try + { + response = JsonSerializer.Deserialize(line); + return response is not null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to parse compactor response."); + return false; + } + } + + private string? ResolveWorkerPath() + { + var baseDir = AppContext.BaseDirectory; + var exeName = OperatingSystem.IsWindows() || _context.IsWine + ? "LightlessCompactorWorker.exe" + : "LightlessCompactorWorker"; + var path = Path.Combine(baseDir, exeName); + return File.Exists(path) ? path : null; + } + + private string BuildArguments() + { + var args = new List { "--pipe", Quote(_pipeName), "--parent", Environment.ProcessId.ToString() }; + if (_context.IsWine) + args.Add("--wine"); + return string.Join(' ', args); + } + + private static string Quote(string value) + { + if (string.IsNullOrEmpty(value)) + return "\"\""; + + if (!value.Contains('"', StringComparison.Ordinal)) + return "\"" + value + "\""; + + return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + private static void TrySetLowPriority(Process process) + { + try + { + if (OperatingSystem.IsWindows()) + process.PriorityClass = ProcessPriorityClass.BelowNormal; + } + catch + { + // ignore + } + } + + private async Task DrainAsync(StreamReader reader, string label) + { + try + { + string? line; + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + { + if (_logger.IsEnabled(LogLevel.Trace)) + _logger.LogTrace("Compactor {label}: {line}", label, line); + } + } + catch + { + // ignore + } + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // ignore + } + } + + private sealed class CompactorRequest + { + public string Type { get; init; } = "compact"; + public string? Path { get; init; } + } + + private sealed class CompactorResponse + { + public bool Success { get; init; } + public string? Error { get; init; } + } +} diff --git a/LightlessSync/FileCache/FileCacheManager.cs b/LightlessSync/FileCache/FileCacheManager.cs index b98b441..886f8cc 100644 --- a/LightlessSync/FileCache/FileCacheManager.cs +++ b/LightlessSync/FileCache/FileCacheManager.cs @@ -115,6 +115,35 @@ public sealed class FileCacheManager : IHostedService return true; } + private static bool TryGetHashFromFileName(FileInfo fileInfo, out string hash) + { + hash = Path.GetFileNameWithoutExtension(fileInfo.Name); + if (string.IsNullOrWhiteSpace(hash)) + { + return false; + } + + if (hash.Length is not (40 or 64)) + { + return false; + } + + for (var i = 0; i < hash.Length; i++) + { + var c = hash[i]; + var isHex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!isHex) + { + return false; + } + } + + hash = hash.ToUpperInvariant(); + return true; + } + private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}"; private static bool TryParseVersionHeader(string? line, out int version) @@ -288,6 +317,11 @@ public sealed class FileCacheManager : IHostedService _logger.LogTrace("Creating cache entry for {path}", path); var cacheFolder = _configService.Current.CacheFolder; if (string.IsNullOrEmpty(cacheFolder)) return null; + if (TryGetHashFromFileName(fi, out var hash)) + { + return CreateCacheEntryWithKnownHash(fi.FullName, hash); + } + return CreateFileEntity(cacheFolder, CachePrefix, fi); } diff --git a/LightlessSync/FileCache/PluginCompactorContext.cs b/LightlessSync/FileCache/PluginCompactorContext.cs new file mode 100644 index 0000000..c466a94 --- /dev/null +++ b/LightlessSync/FileCache/PluginCompactorContext.cs @@ -0,0 +1,20 @@ +using LightlessSync.LightlessConfiguration; +using LightlessSync.Services; + +namespace LightlessSync.FileCache; + +internal sealed class PluginCompactorContext : ICompactorContext +{ + private readonly LightlessConfigService _configService; + private readonly DalamudUtilService _dalamudUtilService; + + public PluginCompactorContext(LightlessConfigService configService, DalamudUtilService dalamudUtilService) + { + _configService = configService; + _dalamudUtilService = dalamudUtilService; + } + + public bool UseCompactor => _configService.Current.UseCompactor; + public string CacheFolder => _configService.Current.CacheFolder; + public bool IsWine => _dalamudUtilService.IsWine; +} diff --git a/LightlessSync/FileCache/TransientResourceManager.cs b/LightlessSync/FileCache/TransientResourceManager.cs index 11073dc..1397159 100644 --- a/LightlessSync/FileCache/TransientResourceManager.cs +++ b/LightlessSync/FileCache/TransientResourceManager.cs @@ -25,7 +25,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase 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 string[] _handledFileTypesWithRecording; private readonly HashSet _playerRelatedPointers = []; private readonly object _playerRelatedLock = new(); private readonly ConcurrentDictionary _playerRelatedByAddress = new(); @@ -42,8 +41,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _dalamudUtil = dalamudUtil; _actorObjectService = actorObjectService; _gameObjectHandlerFactory = gameObjectHandlerFactory; - _handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray(); - Mediator.Subscribe(this, Manager_PenumbraResourceLoadEvent); Mediator.Subscribe(this, msg => HandleActorTracked(msg.Descriptor)); Mediator.Subscribe(this, msg => HandleActorUntracked(msg.Descriptor)); @@ -523,46 +520,51 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg) { + var gamePath = msg.GamePath.ToLowerInvariant(); var gameObjectAddress = msg.GameObject; - if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) - { - if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind)) - { - objectKind = ownedKind; - } - else - { - return; - } - } - - var gamePath = NormalizeGamePath(msg.GamePath); - if (string.IsNullOrEmpty(gamePath)) - { - return; - } + var filePath = msg.FilePath; // ignore files already processed this frame + if (_cachedHandledPaths.Contains(gamePath)) return; + lock (_cacheAdditionLock) { - if (!_cachedHandledPaths.Add(gamePath)) - { - return; - } + _cachedHandledPaths.Add(gamePath); + } + + // replace individual mtrl stuff + if (filePath.StartsWith("|", StringComparison.OrdinalIgnoreCase)) + { + filePath = filePath.Split("|")[2]; + } + // replace filepath + filePath = filePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); + + // ignore files that are the same + var replacedGamePath = gamePath.ToLowerInvariant().Replace("\\", "/", StringComparison.OrdinalIgnoreCase); + if (string.Equals(filePath, replacedGamePath, StringComparison.OrdinalIgnoreCase)) + { + return; } // ignore files to not handle - var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes; - if (!HasHandledFileType(gamePath, handledTypes)) + var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes; + if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase))) { + lock (_cacheAdditionLock) + { + _cachedHandledPaths.Add(gamePath); + } return; } - var filePath = NormalizeFilePath(msg.FilePath); - - // ignore files that are the same - if (string.Equals(filePath, gamePath, StringComparison.Ordinal)) + // ignore files not belonging to anything player related + if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) { + lock (_cacheAdditionLock) + { + _cachedHandledPaths.Add(gamePath); + } return; } @@ -577,12 +579,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase _playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner); bool alreadyTransient = false; - bool transientContains = transientResources.Contains(gamePath); - bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath)); + bool transientContains = transientResources.Contains(replacedGamePath); + bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value) + .Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase)); if (transientContains || semiTransientContains) { if (!IsTransientRecording) - Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", gamePath, filePath, + Logger.LogTrace("Not adding {replacedPath} => {filePath}, Reason: Transient: {contains}, SemiTransient: {contains2}", replacedGamePath, filePath, transientContains, semiTransientContains); alreadyTransient = true; } @@ -590,10 +593,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { if (!IsTransientRecording) { - bool isAdded = transientResources.Add(gamePath); + bool isAdded = transientResources.Add(replacedGamePath); if (isAdded) { - Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", gamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); + Logger.LogDebug("Adding {replacedGamePath} for {gameObject} ({filePath})", replacedGamePath, owner?.ToString() ?? gameObjectAddress.ToString("X"), filePath); SendTransients(gameObjectAddress, objectKind); } } @@ -601,7 +604,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase if (owner != null && IsTransientRecording) { - _recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); + _recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); } } @@ -700,4 +703,4 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase { public bool AddTransient { get; set; } } -} \ No newline at end of file +} diff --git a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs index e077eab..db63c2a 100644 --- a/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs +++ b/LightlessSync/Interop/Ipc/IpcCallerPenumbra.cs @@ -4,7 +4,6 @@ using LightlessSync.Interop.Ipc.Penumbra; 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; @@ -36,8 +35,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase IDalamudPluginInterface pluginInterface, DalamudUtilService dalamudUtil, LightlessMediator mediator, - RedrawManager redrawManager, - ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor) + RedrawManager redrawManager) : base(logger, mediator, pluginInterface, PenumbraDescriptor) { _penumbraEnabled = new GetEnabledState(pluginInterface); _penumbraGetModDirectory = new GetModDirectory(pluginInterface); @@ -46,7 +44,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase _penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged); _collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator)); - _resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator, actorObjectService)); + _resources = RegisterInterop(new PenumbraResource(logger, pluginInterface, dalamudUtil, mediator)); _redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager)); _textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw)); @@ -104,8 +102,8 @@ public sealed class IpcCallerPenumbra : IpcServiceBase 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 ConvertTextureFiles(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token, bool requestRedraw = true) + => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw); public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) => _textures.ConvertTextureFileDirectAsync(job, token); diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs index 73da7cc..19a1e7f 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraResource.cs @@ -2,9 +2,9 @@ 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 System.Globalization; using Penumbra.Api.Helpers; using Penumbra.Api.IpcSubscribers; @@ -12,7 +12,6 @@ namespace LightlessSync.Interop.Ipc.Penumbra; public sealed class PenumbraResource : PenumbraBase { - private readonly ActorObjectService _actorObjectService; private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; private readonly ResolveGameObjectPath _resolveGameObjectPath; private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath; @@ -24,10 +23,8 @@ public sealed class PenumbraResource : PenumbraBase ILogger logger, IDalamudPluginInterface pluginInterface, DalamudUtilService dalamudUtil, - LightlessMediator mediator, - ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator) + LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator) { - _actorObjectService = actorObjectService; _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); _resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface); _reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface); @@ -79,22 +76,10 @@ public sealed class PenumbraResource : PenumbraBase private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath) { - if (ptr == nint.Zero) + if (ptr != nint.Zero && string.Compare(gamePath, resolvedPath, ignoreCase: true, CultureInfo.InvariantCulture) != 0) { - return; + Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath)); } - - if (!_actorObjectService.TryGetOwnedKind(ptr, out _)) - { - return; - } - - if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0) - { - return; - } - - Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath)); } protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) diff --git a/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs index e12fd7b..453d211 100644 --- a/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs +++ b/LightlessSync/Interop/Ipc/Penumbra/PenumbraTexture.cs @@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase public override string Name => "Penumbra.Textures"; - public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token) + public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList jobs, IProgress? progress, CancellationToken token, bool requestRedraw) { if (!IsAvailable || jobs.Count == 0) { @@ -57,7 +57,7 @@ public sealed class PenumbraTexture : PenumbraBase Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync))); } - if (completedJobs > 0 && !token.IsCancellationRequested) + if (requestRedraw && completedJobs > 0 && !token.IsCancellationRequested) { await DalamudUtil.RunOnFrameworkThread(async () => { diff --git a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs index 5532d78..48db57e 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/ChatConfig.cs @@ -12,6 +12,9 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowMessageTimestamps { get; set; } = true; public bool ShowNotesInSyncshellChat { get; set; } = true; public bool EnableAnimatedEmotes { get; set; } = true; + public float EmoteScale { get; set; } = 1.5f; + public bool EnableMentionNotifications { get; set; } = true; + public bool AutoOpenChatOnNewMessage { get; set; } = false; public float ChatWindowOpacity { get; set; } = .97f; public bool FadeWhenUnfocused { get; set; } = false; public float UnfocusedWindowOpacity { get; set; } = 0.6f; @@ -23,6 +26,9 @@ public sealed class ChatConfig : ILightlessConfiguration public bool ShowWhenUiHidden { get; set; } = true; public bool ShowInCutscenes { get; set; } = true; public bool ShowInGpose { get; set; } = true; + public bool PersistSyncshellHistory { get; set; } = false; public List ChannelOrder { get; set; } = new(); + public Dictionary HiddenChannels { get; set; } = new(StringComparer.Ordinal); + public Dictionary SyncshellChannelHistory { get; set; } = new(StringComparer.Ordinal); public Dictionary PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal); } diff --git a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs index 8f1a3de..35c1958 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/LightlessConfig.cs @@ -32,6 +32,8 @@ public class LightlessConfig : ILightlessConfiguration public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u); public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests; public bool UseLightlessRedesign { get; set; } = true; + public bool ShowUiWhenUiHidden { get; set; } = true; + public bool ShowUiInGpose { get; set; } = true; public bool EnableRightClickMenus { get; set; } = true; public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both; public string ExportFolder { get; set; } = string.Empty; diff --git a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs index 462a63f..599bea1 100644 --- a/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs +++ b/LightlessSync/LightlessConfiguration/Configurations/PlayerPerformanceConfig.cs @@ -21,11 +21,14 @@ public class PlayerPerformanceConfig : ILightlessConfiguration public bool EnableIndexTextureDownscale { get; set; } = false; public int TextureDownscaleMaxDimension { get; set; } = 2048; public bool OnlyDownscaleUncompressedTextures { get; set; } = true; + public bool EnableUncompressedTextureCompression { get; set; } = false; + public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false; public bool KeepOriginalTextureFiles { get; set; } = false; public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true; public bool EnableModelDecimation { get; set; } = false; - public int ModelDecimationTriangleThreshold { get; set; } = 20_000; + public int ModelDecimationTriangleThreshold { get; set; } = 15_000; public double ModelDecimationTargetRatio { get; set; } = 0.8; + public bool ModelDecimationNormalizeTangents { get; set; } = true; public bool KeepOriginalModelFiles { get; set; } = true; public bool SkipModelDecimationForPreferredPairs { get; set; } = true; public bool ModelDecimationAllowBody { get; set; } = false; diff --git a/LightlessSync/LightlessSync.csproj b/LightlessSync/LightlessSync.csproj index 938d413..d201a7b 100644 --- a/LightlessSync/LightlessSync.csproj +++ b/LightlessSync/LightlessSync.csproj @@ -78,6 +78,8 @@ + + @@ -101,5 +103,13 @@ + + + + + + + + diff --git a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs index 211a6fc..feb6d41 100644 --- a/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs +++ b/LightlessSync/PlayerData/Factories/FileDownloadManagerFactory.cs @@ -19,6 +19,7 @@ public class FileDownloadManagerFactory private readonly TextureDownscaleService _textureDownscaleService; private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; + private readonly FileDownloadDeduplicator _downloadDeduplicator; public FileDownloadManagerFactory( ILoggerFactory loggerFactory, @@ -29,7 +30,8 @@ public class FileDownloadManagerFactory LightlessConfigService configService, TextureDownscaleService textureDownscaleService, ModelDecimationService modelDecimationService, - TextureMetadataHelper textureMetadataHelper) + TextureMetadataHelper textureMetadataHelper, + FileDownloadDeduplicator downloadDeduplicator) { _loggerFactory = loggerFactory; _lightlessMediator = lightlessMediator; @@ -40,6 +42,7 @@ public class FileDownloadManagerFactory _textureDownscaleService = textureDownscaleService; _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; + _downloadDeduplicator = downloadDeduplicator; } public FileDownloadManager Create() @@ -53,6 +56,7 @@ public class FileDownloadManagerFactory _configService, _textureDownscaleService, _modelDecimationService, - _textureMetadataHelper); + _textureMetadataHelper, + _downloadDeduplicator); } } diff --git a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs index 9141a9b..744e503 100644 --- a/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs +++ b/LightlessSync/PlayerData/Factories/PlayerDataFactory.cs @@ -476,7 +476,7 @@ public class PlayerDataFactory if (transientPaths.Count == 0) return (new Dictionary(StringComparer.Ordinal), clearedReplacements); - var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet(StringComparer.Ordinal)) + var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet(StringComparer.Ordinal)) .ConfigureAwait(false); if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries) @@ -692,7 +692,6 @@ public class PlayerDataFactory private async Task> GetFileReplacementsFromPaths( - GameObjectHandler handler, HashSet forwardResolve, HashSet reverseResolve) { @@ -707,59 +706,6 @@ public class PlayerDataFactory var reversePathsLower = reversePaths.Length == 0 ? Array.Empty() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray(); Dictionary> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal); - if (handler.ObjectKind != ObjectKind.Player) - { - var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() => - { - var idx = handler.GetGameObject()?.ObjectIndex; - if (!idx.HasValue) - return ((int?)null, Array.Empty(), Array.Empty()); - - var resolvedForward = new string[forwardPaths.Length]; - for (int i = 0; i < forwardPaths.Length; i++) - resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value); - - var resolvedReverse = new string[reversePaths.Length][]; - for (int i = 0; i < reversePaths.Length; i++) - resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value); - - return (idx, resolvedForward, resolvedReverse); - }).ConfigureAwait(false); - - if (objectIndex.HasValue) - { - for (int i = 0; i < forwardPaths.Length; i++) - { - var filePath = forwardResolved[i]?.ToLowerInvariant(); - if (string.IsNullOrEmpty(filePath)) - continue; - - if (resolvedPaths.TryGetValue(filePath, out var list)) - list.Add(forwardPaths[i].ToLowerInvariant()); - else - { - resolvedPaths[filePath] = [forwardPathsLower[i]]; - } - } - - for (int i = 0; i < reversePaths.Length; i++) - { - var filePath = reversePathsLower[i]; - var reverseResolvedLower = new string[reverseResolved[i].Length]; - for (var j = 0; j < reverseResolvedLower.Length; j++) - { - reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant(); - } - if (resolvedPaths.TryGetValue(filePath, out var list)) - list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant())); - else - resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()]; - } - - return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); - } - } - var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false); for (int i = 0; i < forwardPaths.Length; i++) diff --git a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs index 0566491..d04cc3b 100644 --- a/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/IPairHandlerAdapter.cs @@ -1,43 +1,44 @@ - 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; } - bool PendingModReapply { get; } - bool ModApplyDeferred { get; } - int MissingCriticalMods { get; } - int MissingNonCriticalMods { get; } - int MissingForbiddenMods { get; } - DateTime? InvisibleSinceUtc { get; } - DateTime? VisibilityEvictionDueAtUtc { 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; } + bool PendingModReapply { get; } + bool ModApplyDeferred { get; } + int MissingCriticalMods { get; } + int MissingNonCriticalMods { get; } + int MissingForbiddenMods { 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); + Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken); + bool FetchPerformanceMetricsFromCache(); + void LoadCachedCharacterData(CharacterData data); + void SetUploading(bool uploading); + void SetPaused(bool paused); +} diff --git a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs index b392e62..c4f3e70 100644 --- a/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs +++ b/LightlessSync/PlayerData/Pairs/PairHandlerAdapter.cs @@ -54,6 +54,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa private readonly XivDataAnalyzer _modelAnalyzer; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly LightlessConfigService _configService; + private readonly SemaphoreSlim _metricsComputeGate = new(1, 1); private readonly PairManager _pairManager; private readonly IFramework _framework; private CancellationTokenSource? _applicationCancellationTokenSource; @@ -193,8 +194,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa 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 bool IsDownloading => _downloadManager.IsDownloadingFor(_charaHandler); + public int PendingDownloadCount => _downloadManager.GetPendingDownloadCount(_charaHandler); public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count; public PairHandlerAdapter( @@ -721,6 +722,74 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa return true; } + public async Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken) + { + EnsureInitialized(); + + if (LastReceivedCharacterData is null || IsApplying) + { + return; + } + + if (LastAppliedApproximateVRAMBytes >= 0 + && LastAppliedDataTris >= 0 + && LastAppliedApproximateEffectiveVRAMBytes >= 0 + && LastAppliedApproximateEffectiveTris >= 0) + { + return; + } + + await _metricsComputeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + + if (LastReceivedCharacterData is null) + { + return; + } + + if (LastAppliedApproximateVRAMBytes >= 0 + && LastAppliedDataTris >= 0 + && LastAppliedApproximateEffectiveVRAMBytes >= 0 + && LastAppliedApproximateEffectiveTris >= 0) + { + return; + } + + var sanitized = CloneAndSanitizeLastReceived(out var dataHash); + if (sanitized is null) + { + return; + } + + if (!string.IsNullOrEmpty(dataHash) && TryApplyCachedMetrics(dataHash)) + { + _cachedData = sanitized; + _pairStateCache.Store(Ident, sanitized); + return; + } + + if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0) + { + _playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, sanitized, []); + } + + if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0) + { + await _playerPerformanceService.CheckTriangleUsageThresholds(this, sanitized).ConfigureAwait(false); + } + + StorePerformanceMetrics(sanitized); + _cachedData = sanitized; + _pairStateCache.Store(Ident, sanitized); + } + finally + { + _metricsComputeGate.Release(); + } + } + private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash) { dataHash = null; @@ -1090,6 +1159,19 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods); } + var forceModsForMissing = _pendingModReapply; + if (!forceModsForMissing && HasMissingCachedFiles(characterData)) + { + forceModsForMissing = true; + } + + if (forceModsForMissing) + { + _forceApplyMods = true; + } + + var suppressForcedModRedrawOnForcedApply = suppressForcedModRedraw || forceModsForMissing; + SetUploading(false); Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); @@ -1106,7 +1188,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa "Applying Character Data"))); var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, - forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); + forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply); if (handlerReady && _forceApplyMods) { @@ -1921,7 +2003,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } var handlerForDownload = _charaHandler; - _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); + _pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false)); await _pairDownloadTask.ConfigureAwait(false); @@ -2136,6 +2218,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa } SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); + var hasPap = papOnly.Count > 0; await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); @@ -2148,22 +2231,29 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa if (handlerForApply.Address != nint.Zero) await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); - var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); - if (removedPap > 0) + if (hasPap) { - Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier()); + var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); + if (removedPap > 0) + { + Logger.LogTrace("[{applicationId}] Removed {removedPap} incompatible PAP mappings found for {handler}", _applicationId, removedPap, GetLogIdentifier()); + } + + var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); + foreach (var kv in papOnly) + merged[kv.Key] = kv.Value; + + await _ipcManager.Penumbra.SetTemporaryModsAsync( + Logger, _applicationId, penumbraCollection, + merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) + .ConfigureAwait(false); + + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); + } + else + { + _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); } - - var merged = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer); - foreach (var kv in papOnly) - merged[kv.Key] = kv.Value; - - await _ipcManager.Penumbra.SetTemporaryModsAsync( - Logger, _applicationId, penumbraCollection, - merged.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)) - .ConfigureAwait(false); - - _lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); LastAppliedDataBytes = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) @@ -2218,20 +2308,20 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa _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)) + } + catch (OperationCanceledException) { - IsVisible = false; + 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); @@ -2471,6 +2561,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa (item) => { token.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(item.Hash)) + { + Logger.LogTrace("[BASE-{appBase}] Skipping replacement with empty hash for paths: {paths}", applicationBase, string.Join(", ", item.GamePaths)); + return; + } var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash); if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath)) { @@ -2698,10 +2793,10 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa ApplyCharacterData(pending.ApplicationId, pending.CharacterData, pending.Forced); } catch (Exception ex) - { - Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier()); - } - }); + { + Logger.LogError(ex, "Failed applying queued data for {handler}", GetLogIdentifier()); + } + }); } private void HandleActorTracked(ActorObjectService.ActorDescriptor descriptor) diff --git a/LightlessSync/PlayerData/Pairs/PairLedger.cs b/LightlessSync/PlayerData/Pairs/PairLedger.cs index fdb226e..2fe2205 100644 --- a/LightlessSync/PlayerData/Pairs/PairLedger.cs +++ b/LightlessSync/PlayerData/Pairs/PairLedger.cs @@ -271,7 +271,20 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase try { - handler.ApplyLastReceivedData(forced: true); + _ = Task.Run(async () => + { + try + { + await handler.EnsurePerformanceMetricsAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident); + } + } + }); } catch (Exception ex) { diff --git a/LightlessSync/Plugin.cs b/LightlessSync/Plugin.cs index 4e1ed4e..f14aeda 100644 --- a/LightlessSync/Plugin.cs +++ b/LightlessSync/Plugin.cs @@ -129,12 +129,15 @@ public sealed class Plugin : IDalamudPlugin 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(); @@ -331,8 +334,7 @@ public sealed class Plugin : IDalamudPlugin pluginInterface, sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); + sp.GetRequiredService())); services.AddSingleton(sp => new IpcCallerGlamourer( sp.GetRequiredService>(), @@ -516,6 +518,7 @@ public sealed class Plugin : IDalamudPlugin sp.GetRequiredService>(), pluginInterface.UiBuilder, sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetServices(), sp.GetRequiredService(), diff --git a/LightlessSync/Services/ActorTracking/ActorObjectService.cs b/LightlessSync/Services/ActorTracking/ActorObjectService.cs index a00839e..bb9ce7a 100644 --- a/LightlessSync/Services/ActorTracking/ActorObjectService.cs +++ b/LightlessSync/Services/ActorTracking/ActorObjectService.cs @@ -571,36 +571,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS if (localPlayerAddress == nint.Zero) return nint.Zero; - var playerObject = (GameObject*)localPlayerAddress; - var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); if (ownerEntityId == 0) return nint.Zero; - if (candidateAddress != nint.Zero) - { - var candidate = (GameObject*)candidateAddress; - var candidateKind = (DalamudObjectKind)candidate->ObjectKind; - if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) - { - if (ResolveOwnerId(candidate) == ownerEntityId) - return candidateAddress; - } - } + var playerObject = (GameObject*)localPlayerAddress; + var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1); + if (candidateAddress == nint.Zero) + return nint.Zero; - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion)) - continue; - - var candidate = (GameObject*)obj.Address; - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - - return nint.Zero; + var candidate = (GameObject*)candidateAddress; + var candidateKind = (DalamudObjectKind)candidate->ObjectKind; + return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion + ? candidateAddress + : nint.Zero; } private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) @@ -620,22 +603,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS } } - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - continue; - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) - continue; - - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - return nint.Zero; } @@ -655,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS return candidate; } } - - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress) - continue; - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - continue; - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy) - continue; - - if (ResolveOwnerId(candidate) == ownerEntityId) - return obj.Address; - } - return nint.Zero; } diff --git a/LightlessSync/Services/Chat/ZoneChatService.cs b/LightlessSync/Services/Chat/ZoneChatService.cs index 54dd2d9..67ae117 100644 --- a/LightlessSync/Services/Chat/ZoneChatService.cs +++ b/LightlessSync/Services/Chat/ZoneChatService.cs @@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using LightlessSync.UI.Services; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Models; +using System.Text.Json; +using System.Text.Json.Serialization; namespace LightlessSync.Services.Chat; public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService { - private const int MaxMessageHistory = 150; + private const int MaxMessageHistory = 200; 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 = 100; private const int MaxReportContextLength = 1000; + private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; private readonly ApiController _apiController; private readonly DalamudUtilService _dalamudUtilService; @@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS public Task StartAsync(CancellationToken cancellationToken) { + LoadPersistedSyncshellHistory(); Mediator.Subscribe(this, _ => HandleLogin()); Mediator.Subscribe(this, _ => HandleLogout()); Mediator.Subscribe(this, _ => ScheduleZonePresenceUpdate()); @@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS 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); + ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey(); + string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); + bool fromSelf = IsMessageFromSelf(dto, key); + ChatMessageEntry message = BuildMessage(dto, fromSelf); + bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications; + bool notifyMention = mentionNotificationsEnabled + && !fromSelf + && descriptor.Type == ChatChannelType.Group + && TryGetSelfMentionToken(dto.Message, out _); + + string? mentionChannelName = null; + string? mentionSenderName = null; bool publishChannelList = false; + bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory; + List? persistedMessages = null; + string? persistedChannelKey = null; using (_sync.EnterScope()) { @@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS state.Messages.RemoveAt(0); } + if (notifyMention) + { + mentionChannelName = state.DisplayName; + mentionSenderName = message.DisplayName; + } + if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) { state.HasUnread = false; @@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS } MarkChannelsSnapshotDirtyLocked(); + + if (shouldPersistHistory && state.Type == ChatChannelType.Group) + { + persistedChannelKey = state.Key; + persistedMessages = BuildPersistedHistoryLocked(state); + } } Mediator.Publish(new ChatChannelMessageAdded(key, message)); + if (persistedMessages is not null && persistedChannelKey is not null) + { + PersistSyncshellHistory(persistedChannelKey, persistedMessages); + } + + if (notifyMention) + { + string channelName = mentionChannelName ?? "Syncshell"; + string senderName = mentionSenderName ?? "Someone"; + string notificationText = $"You were mentioned by {senderName} in {channelName}."; + Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info)); + } + if (publishChannelList) { using (_sync.EnterScope()) @@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return false; } + private bool TryGetSelfMentionToken(string message, out string matchedToken) + { + matchedToken = string.Empty; + if (string.IsNullOrWhiteSpace(message)) + { + return false; + } + + HashSet tokens = BuildSelfMentionTokens(); + if (tokens.Count == 0) + { + return false; + } + + return TryFindMentionToken(message, tokens, out matchedToken); + } + + private HashSet BuildSelfMentionTokens() + { + HashSet tokens = new(StringComparer.OrdinalIgnoreCase); + string uid = _apiController.UID; + if (IsValidMentionToken(uid)) + { + tokens.Add(uid); + } + + string displayName = _apiController.DisplayName; + if (IsValidMentionToken(displayName)) + { + tokens.Add(displayName); + } + + return tokens; + } + + private static bool IsValidMentionToken(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + for (int i = 0; i < value.Length; i++) + { + if (!IsMentionChar(value[i])) + { + return false; + } + } + + return true; + } + + private static bool TryFindMentionToken(string message, IReadOnlyCollection tokens, out string matchedToken) + { + matchedToken = string.Empty; + if (tokens.Count == 0 || string.IsNullOrEmpty(message)) + { + return false; + } + + int index = 0; + while (index < message.Length) + { + if (message[index] != '@') + { + index++; + continue; + } + + if (index > 0 && IsMentionChar(message[index - 1])) + { + index++; + continue; + } + + int start = index + 1; + int end = start; + while (end < message.Length && IsMentionChar(message[end])) + { + end++; + } + + if (end == start) + { + index++; + continue; + } + + string token = message.Substring(start, end - start); + if (tokens.Contains(token)) + { + matchedToken = token; + return true; + } + + index = end; + } + + return false; + } + + private static bool IsMentionChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\''; + } + private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf) { var displayName = ResolveDisplayName(dto, fromSelf); @@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS return 0; } + private void LoadPersistedSyncshellHistory() + { + if (!_chatConfigService.Current.PersistSyncshellHistory) + { + return; + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persisted.Count == 0) + { + return; + } + + List invalidKeys = new(); + foreach (KeyValuePair entry in persisted) + { + if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value)) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (!TryDecodePersistedHistory(entry.Value, out List persistedMessages)) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (persistedMessages.Count == 0) + { + invalidKeys.Add(entry.Key); + continue; + } + + if (persistedMessages.Count > MaxMessageHistory) + { + int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory); + persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex); + } + + List restoredMessages = new(persistedMessages.Count); + foreach (PersistedChatMessage persistedMessage in persistedMessages) + { + if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage)) + { + continue; + } + + restoredMessages.Add(restoredMessage); + } + + if (restoredMessages.Count == 0) + { + invalidKeys.Add(entry.Key); + continue; + } + + using (_sync.EnterScope()) + { + _messageHistoryCache[entry.Key] = restoredMessages; + } + } + + if (invalidKeys.Count > 0) + { + foreach (string key in invalidKeys) + { + persisted.Remove(key); + } + + _chatConfigService.Save(); + } + } + + private List BuildPersistedHistoryLocked(ChatChannelState state) + { + int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory); + List persistedMessages = new(state.Messages.Count - startIndex); + for (int i = startIndex; i < state.Messages.Count; i++) + { + ChatMessageEntry entry = state.Messages[i]; + if (entry.Payload is not { } payload) + { + continue; + } + + persistedMessages.Add(new PersistedChatMessage( + payload.Message, + entry.DisplayName, + entry.FromSelf, + entry.ReceivedAtUtc, + payload.SentAtUtc)); + } + + return persistedMessages; + } + + private void PersistSyncshellHistory(string channelKey, List persistedMessages) + { + if (!_chatConfigService.Current.PersistSyncshellHistory) + { + return; + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persistedMessages.Count == 0) + { + if (persisted.Remove(channelKey)) + { + _chatConfigService.Save(); + } + + return; + } + + string? base64 = EncodePersistedMessages(persistedMessages); + if (string.IsNullOrWhiteSpace(base64)) + { + if (persisted.Remove(channelKey)) + { + _chatConfigService.Save(); + } + + return; + } + + persisted[channelKey] = base64; + _chatConfigService.Save(); + } + + private static string? EncodePersistedMessages(List persistedMessages) + { + if (persistedMessages.Count == 0) + { + return null; + } + + byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions); + return Convert.ToBase64String(jsonBytes); + } + + private static bool TryDecodePersistedHistory(string base64, out List persistedMessages) + { + persistedMessages = new List(); + if (string.IsNullOrWhiteSpace(base64)) + { + return false; + } + + try + { + byte[] jsonBytes = Convert.FromBase64String(base64); + List? decoded = JsonSerializer.Deserialize>(jsonBytes, PersistedHistorySerializerOptions); + if (decoded is null) + { + return false; + } + + persistedMessages = decoded; + return true; + } + catch + { + return false; + } + } + + private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage) + { + restoredMessage = default; + string messageText = persistedMessage.Message; + DateTime sentAtUtc = persistedMessage.SentAtUtc; + if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy) + { + messageText = legacy.Message; + sentAtUtc = legacy.SentAtUtc; + } + + if (string.IsNullOrWhiteSpace(messageText)) + { + return false; + } + + ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey); + ChatSenderDescriptor sender = new ChatSenderDescriptor( + ChatSenderKind.Anonymous, + string.Empty, + null, + null, + null, + false); + + ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty); + restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc); + return true; + } + + private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey) + { + if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal)) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Zone }; + } + + int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal); + if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Group }; + } + + string typeValue = channelKey[..separatorIndex]; + if (!int.TryParse(typeValue, out int parsedType)) + { + return new ChatChannelDescriptor { Type = ChatChannelType.Group }; + } + + string customKey = channelKey[(separatorIndex + 1)..]; + ChatChannelType channelType = parsedType switch + { + (int)ChatChannelType.Zone => ChatChannelType.Zone, + (int)ChatChannelType.Group => ChatChannelType.Group, + _ => ChatChannelType.Group + }; + + return new ChatChannelDescriptor + { + Type = channelType, + CustomKey = customKey + }; + } + + public void ClearPersistedSyncshellHistory(bool clearLoadedMessages) + { + bool shouldPublish = false; + bool saveConfig = false; + + using (_sync.EnterScope()) + { + Dictionary> cache = _messageHistoryCache; + if (cache.Count > 0) + { + List keysToRemove = new(); + foreach (string key in cache.Keys) + { + if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal)) + { + keysToRemove.Add(key); + } + } + + foreach (string key in keysToRemove) + { + cache.Remove(key); + } + + if (keysToRemove.Count > 0) + { + shouldPublish = true; + } + } + + if (clearLoadedMessages) + { + foreach (ChatChannelState state in _channels.Values) + { + if (state.Type != ChatChannelType.Group) + { + continue; + } + + if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread) + { + continue; + } + + state.Messages.Clear(); + state.HasUnread = false; + state.UnreadCount = 0; + _lastReadCounts[state.Key] = 0; + shouldPublish = true; + } + } + + Dictionary persisted = _chatConfigService.Current.SyncshellChannelHistory; + if (persisted.Count > 0) + { + persisted.Clear(); + saveConfig = true; + } + + if (shouldPublish) + { + MarkChannelsSnapshotDirtyLocked(); + } + } + + if (saveConfig) + { + _chatConfigService.Save(); + } + + if (shouldPublish) + { + PublishChannelListChanged(); + } + } + private sealed class ChatChannelState { public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) @@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS bool IsOwner); private readonly record struct PendingSelfMessage(string ChannelKey, string Message); + + public sealed record PersistedChatMessage( + string Message = "", + string DisplayName = "", + bool FromSelf = false, + DateTime ReceivedAtUtc = default, + DateTime SentAtUtc = default, + [property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null); } diff --git a/LightlessSync/Services/DalamudUtilService.cs b/LightlessSync/Services/DalamudUtilService.cs index 45fb182..2af13e1 100644 --- a/LightlessSync/Services/DalamudUtilService.cs +++ b/LightlessSync/Services/DalamudUtilService.cs @@ -424,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber if (playerPointer == IntPtr.Zero) return IntPtr.Zero; var playerAddress = playerPointer.Value; - var ownerEntityId = ((Character*)playerAddress)->EntityId; - var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); - if (ownerEntityId == 0) return candidateAddress; - - if (playerAddress == _actorObjectService.LocalPlayerAddress) - { - var localOwned = _actorObjectService.LocalMinionOrMountAddress; - if (localOwned != nint.Zero) - { - return localOwned; - } - } - - if (candidateAddress != nint.Zero) - { - var candidate = (GameObject*)candidateAddress; - var candidateKind = (DalamudObjectKind)candidate->ObjectKind; - if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion) - && ResolveOwnerId(candidate) == ownerEntityId) - { - return candidateAddress; - } - } - - var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind => - kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion); - if (ownedObject != nint.Zero) - { - return ownedObject; - } - - return candidateAddress; + return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1); } public async Task GetMinionOrMountAsync(IntPtr? playerPointer = null) @@ -485,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber } } - return FindOwnedPet(ownerEntityId, ownerAddress); + return IntPtr.Zero; } public async Task GetPetAsync(IntPtr? playerPointer = null) @@ -493,69 +462,6 @@ 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 unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress) - { - if (ownerEntityId == 0) - { - return nint.Zero; - } - - foreach (var obj in _objectTable) - { - if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress) - { - continue; - } - - if (obj.ObjectKind != DalamudObjectKind.BattleNpc) - { - continue; - } - - var candidate = (GameObject*)obj.Address; - if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet) - { - continue; - } - - if (ResolveOwnerId(candidate) == ownerEntityId) - { - return obj.Address; - } - } - - return nint.Zero; - } - private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId) { if (candidate == null) diff --git a/LightlessSync/Services/Mediator/Messages.cs b/LightlessSync/Services/Mediator/Messages.cs index e6db9e7..f3cbd75 100644 --- a/LightlessSync/Services/Mediator/Messages.cs +++ b/LightlessSync/Services/Mediator/Messages.cs @@ -21,6 +21,12 @@ public record SwitchToIntroUiMessage : MessageBase; public record SwitchToMainUiMessage : MessageBase; public record OpenSettingsUiMessage : MessageBase; public record OpenLightfinderSettingsMessage : MessageBase; +public enum PerformanceSettingsSection +{ + TextureOptimization, + ModelOptimization, +} +public record OpenPerformanceSettingsMessage(PerformanceSettingsSection Section) : MessageBase; public record DalamudLoginMessage : MessageBase; public record DalamudLogoutMessage : MessageBase; public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage; diff --git a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs index a7af13f..c47f3f4 100644 --- a/LightlessSync/Services/ModelDecimation/MdlDecimator.cs +++ b/LightlessSync/Services/ModelDecimation/MdlDecimator.cs @@ -10,7 +10,7 @@ using MdlFile = Penumbra.GameData.Files.MdlFile; using MsLogger = Microsoft.Extensions.Logging.ILogger; namespace LightlessSync.Services.ModelDecimation; - + // if you're coming from another sync service, then kindly fuck off. lightless ftw lil bro internal static class MdlDecimator { private const int MaxStreams = 3; @@ -22,6 +22,7 @@ internal static class MdlDecimator MdlFile.VertexUsage.Position, MdlFile.VertexUsage.Normal, MdlFile.VertexUsage.Tangent1, + MdlFile.VertexUsage.Tangent2, MdlFile.VertexUsage.UV, MdlFile.VertexUsage.Color, MdlFile.VertexUsage.BlendWeights, @@ -30,6 +31,7 @@ internal static class MdlDecimator private static readonly HashSet SupportedTypes = [ + MdlFile.VertexType.Single1, MdlFile.VertexType.Single2, MdlFile.VertexType.Single3, MdlFile.VertexType.Single4, @@ -37,9 +39,15 @@ internal static class MdlDecimator MdlFile.VertexType.Half4, MdlFile.VertexType.UByte4, MdlFile.VertexType.NByte4, + MdlFile.VertexType.Short2, + MdlFile.VertexType.Short4, + MdlFile.VertexType.NShort2, + MdlFile.VertexType.NShort4, + MdlFile.VertexType.UShort2, + MdlFile.VertexType.UShort4, ]; - public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, MsLogger logger) + public static bool TryDecimate(string sourcePath, string destinationPath, int triangleThreshold, double targetRatio, bool normalizeTangents, MsLogger logger) { try { @@ -116,7 +124,7 @@ internal static class MdlDecimator bool decimated; if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd - && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, + && TryProcessMesh(mdl, lodIndex, meshIndex, mesh, meshSubMeshes, triangleThreshold, targetRatio, normalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, @@ -309,6 +317,7 @@ internal static class MdlDecimator MdlStructs.SubmeshStruct[] meshSubMeshes, int triangleThreshold, double targetRatio, + bool normalizeTangents, out MeshStruct updatedMesh, out MdlStructs.SubmeshStruct[] updatedSubMeshes, out byte[][] vertexStreams, @@ -370,7 +379,7 @@ internal static class MdlDecimator return false; } - if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) + if (!TryEncodeMeshData(decimatedMesh, format, mesh, meshSubMeshes, normalizeTangents, out updatedMesh, out updatedSubMeshes, out vertexStreams, out indices, out var encodeReason)) { logger.LogDebug("Mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason); return false; @@ -405,11 +414,26 @@ internal static class MdlDecimator mesh.Normals = decoded.Normals; } + if (decoded.PositionWs != null) + { + mesh.PositionWs = decoded.PositionWs; + } + + if (decoded.NormalWs != null) + { + mesh.NormalWs = decoded.NormalWs; + } + if (decoded.Tangents != null) { mesh.Tangents = decoded.Tangents; } + if (decoded.Tangents2 != null) + { + mesh.Tangents2 = decoded.Tangents2; + } + if (decoded.Colors != null) { mesh.Colors = decoded.Colors; @@ -453,9 +477,12 @@ internal static class MdlDecimator var vertexCount = mesh.VertexCount; var positions = new Vector3d[vertexCount]; Vector3[]? normals = format.HasNormals ? new Vector3[vertexCount] : null; - Vector4[]? tangents = format.HasTangents ? new Vector4[vertexCount] : null; + Vector4[]? tangents = format.HasTangent1 ? new Vector4[vertexCount] : null; + Vector4[]? tangents2 = format.HasTangent2 ? new Vector4[vertexCount] : null; Vector4[]? colors = format.HasColors ? new Vector4[vertexCount] : null; BoneWeight[]? boneWeights = format.HasSkinning ? new BoneWeight[vertexCount] : null; + float[]? positionWs = format.HasPositionW ? new float[vertexCount] : null; + float[]? normalWs = format.HasNormalW ? new float[vertexCount] : null; Vector2[][]? uvChannels = null; if (format.UvChannelCount > 0) @@ -477,7 +504,7 @@ internal static class MdlDecimator var uvLookup = format.UvElements.ToDictionary(static element => ElementKey.From(element.Element), static element => element); for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) { - byte[]? indices = null; + int[]? indices = null; float[]? weights = null; foreach (var element in format.SortedElements) @@ -489,14 +516,31 @@ internal static class MdlDecimator switch (usage) { case MdlFile.VertexUsage.Position: - positions[vertexIndex] = ReadPosition(type, stream); + if (type == MdlFile.VertexType.Single4 && positionWs != null) + { + positions[vertexIndex] = ReadPositionWithW(stream, out positionWs[vertexIndex]); + } + else + { + positions[vertexIndex] = ReadPosition(type, stream); + } break; case MdlFile.VertexUsage.Normal when normals != null: - normals[vertexIndex] = ReadNormal(type, stream); + if (type == MdlFile.VertexType.Single4 && normalWs != null) + { + normals[vertexIndex] = ReadNormalWithW(stream, out normalWs[vertexIndex]); + } + else + { + normals[vertexIndex] = ReadNormal(type, stream); + } break; case MdlFile.VertexUsage.Tangent1 when tangents != null: tangents[vertexIndex] = ReadTangent(type, stream); break; + case MdlFile.VertexUsage.Tangent2 when tangents2 != null: + tangents2[vertexIndex] = ReadTangent(type, stream); + break; case MdlFile.VertexUsage.Color when colors != null: colors[vertexIndex] = ReadColor(type, stream); break; @@ -516,6 +560,7 @@ internal static class MdlDecimator break; default: if (usage == MdlFile.VertexUsage.Normal || usage == MdlFile.VertexUsage.Tangent1 + || usage == MdlFile.VertexUsage.Tangent2 || usage == MdlFile.VertexUsage.Color) { _ = ReadAndDiscard(type, stream); @@ -537,7 +582,7 @@ internal static class MdlDecimator } } - decoded = new DecodedMeshData(positions, normals, tangents, colors, boneWeights, uvChannels); + decoded = new DecodedMeshData(positions, normals, tangents, tangents2, colors, boneWeights, uvChannels, positionWs, normalWs); return true; } @@ -546,6 +591,7 @@ internal static class MdlDecimator VertexFormat format, MeshStruct originalMesh, MdlStructs.SubmeshStruct[] originalSubMeshes, + bool normalizeTangents, out MeshStruct updatedMesh, out MdlStructs.SubmeshStruct[] updatedSubMeshes, out byte[][] vertexStreams, @@ -567,8 +613,11 @@ internal static class MdlDecimator var normals = decimatedMesh.Normals; var tangents = decimatedMesh.Tangents; + var tangents2 = decimatedMesh.Tangents2; var colors = decimatedMesh.Colors; var boneWeights = decimatedMesh.BoneWeights; + var positionWs = decimatedMesh.PositionWs; + var normalWs = decimatedMesh.NormalWs; if (format.HasNormals && normals == null) { @@ -576,12 +625,24 @@ internal static class MdlDecimator return false; } - if (format.HasTangents && tangents == null) + if (format.HasTangent1 && tangents == null) { - reason = "Missing tangents after decimation."; + reason = "Missing tangent1 after decimation."; return false; } + if (format.HasTangent2 && tangents2 == null) + { + reason = "Missing tangent2 after decimation."; + return false; + } + + if (normalizeTangents) + { + NormalizeTangents(tangents, clampW: true); + NormalizeTangents(tangents2, clampW: true); + } + if (format.HasColors && colors == null) { reason = "Missing colors after decimation."; @@ -594,6 +655,18 @@ internal static class MdlDecimator return false; } + if (format.HasPositionW && positionWs == null) + { + reason = "Missing position W after decimation."; + return false; + } + + if (format.HasNormalW && normalWs == null) + { + reason = "Missing normal W after decimation."; + return false; + } + var uvChannels = Array.Empty(); if (format.UvChannelCount > 0) { @@ -659,14 +732,17 @@ internal static class MdlDecimator switch (usage) { case MdlFile.VertexUsage.Position: - WritePosition(type, decimatedMesh.Vertices[vertexIndex], target); + WritePosition(type, decimatedMesh.Vertices[vertexIndex], target, positionWs != null ? positionWs[vertexIndex] : null); break; case MdlFile.VertexUsage.Normal when normals != null: - WriteNormal(type, normals[vertexIndex], target); + WriteNormal(type, normals[vertexIndex], target, normalWs != null ? normalWs[vertexIndex] : null); break; case MdlFile.VertexUsage.Tangent1 when tangents != null: WriteTangent(type, tangents[vertexIndex], target); break; + case MdlFile.VertexUsage.Tangent2 when tangents2 != null: + WriteTangent(type, tangents2[vertexIndex], target); + break; case MdlFile.VertexUsage.Color when colors != null: WriteColor(type, colors[vertexIndex], target); break; @@ -876,26 +952,50 @@ internal static class MdlDecimator if (normalElements.Length == 1) { var normalType = (MdlFile.VertexType)normalElements[0].Type; - if (normalType != MdlFile.VertexType.Single3 && normalType != MdlFile.VertexType.Single4 && normalType != MdlFile.VertexType.NByte4) + if (normalType != MdlFile.VertexType.Single3 + && normalType != MdlFile.VertexType.Single4 + && normalType != MdlFile.VertexType.NByte4 + && normalType != MdlFile.VertexType.NShort4) { reason = "Unsupported normal element type."; return false; } } - var tangentElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); - if (tangentElements.Length > 1) + var tangent1Elements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); + if (tangent1Elements.Length > 1) { - reason = "Multiple tangent elements unsupported."; + reason = "Multiple tangent1 elements unsupported."; return false; } - if (tangentElements.Length == 1) + if (tangent1Elements.Length == 1) { - var tangentType = (MdlFile.VertexType)tangentElements[0].Type; - if (tangentType != MdlFile.VertexType.Single4 && tangentType != MdlFile.VertexType.NByte4) + var tangentType = (MdlFile.VertexType)tangent1Elements[0].Type; + if (tangentType != MdlFile.VertexType.Single4 + && tangentType != MdlFile.VertexType.NByte4 + && tangentType != MdlFile.VertexType.NShort4) { - reason = "Unsupported tangent element type."; + reason = "Unsupported tangent1 element type."; + return false; + } + } + + var tangent2Elements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent2).ToArray(); + if (tangent2Elements.Length > 1) + { + reason = "Multiple tangent2 elements unsupported."; + return false; + } + + if (tangent2Elements.Length == 1) + { + var tangentType = (MdlFile.VertexType)tangent2Elements[0].Type; + if (tangentType != MdlFile.VertexType.Single4 + && tangentType != MdlFile.VertexType.NByte4 + && tangentType != MdlFile.VertexType.NShort4) + { + reason = "Unsupported tangent2 element type."; return false; } } @@ -911,7 +1011,12 @@ internal static class MdlDecimator if (colorElements.Length == 1) { var colorType = (MdlFile.VertexType)colorElements[0].Type; - if (colorType != MdlFile.VertexType.UByte4 && colorType != MdlFile.VertexType.NByte4 && colorType != MdlFile.VertexType.Single4) + if (colorType != MdlFile.VertexType.UByte4 + && colorType != MdlFile.VertexType.NByte4 + && colorType != MdlFile.VertexType.Single4 + && colorType != MdlFile.VertexType.Short4 + && colorType != MdlFile.VertexType.NShort4 + && colorType != MdlFile.VertexType.UShort4) { reason = "Unsupported color element type."; return false; @@ -937,14 +1042,18 @@ internal static class MdlDecimator if (blendIndicesElements.Length == 1) { var indexType = (MdlFile.VertexType)blendIndicesElements[0].Type; - if (indexType != MdlFile.VertexType.UByte4) + if (indexType != MdlFile.VertexType.UByte4 && indexType != MdlFile.VertexType.UShort4) { reason = "Unsupported blend index type."; return false; } var weightType = (MdlFile.VertexType)blendWeightsElements[0].Type; - if (weightType != MdlFile.VertexType.UByte4 && weightType != MdlFile.VertexType.NByte4 && weightType != MdlFile.VertexType.Single4) + if (weightType != MdlFile.VertexType.UByte4 + && weightType != MdlFile.VertexType.NByte4 + && weightType != MdlFile.VertexType.Single4 + && weightType != MdlFile.VertexType.UShort4 + && weightType != MdlFile.VertexType.NShort4) { reason = "Unsupported blend weight type."; return false; @@ -956,11 +1065,14 @@ internal static class MdlDecimator return false; } + var positionElement = positionElements[0]; var sortedElements = elements.OrderBy(static element => element.Offset).ToList(); format = new VertexFormat( sortedElements, + positionElement, normalElements.Length == 1 ? normalElements[0] : (MdlStructs.VertexElement?)null, - tangentElements.Length == 1 ? tangentElements[0] : (MdlStructs.VertexElement?)null, + tangent1Elements.Length == 1 ? tangent1Elements[0] : (MdlStructs.VertexElement?)null, + tangent2Elements.Length == 1 ? tangent2Elements[0] : (MdlStructs.VertexElement?)null, colorElement, blendIndicesElements.Length == 1 ? blendIndicesElements[0] : (MdlStructs.VertexElement?)null, blendWeightsElements.Length == 1 ? blendWeightsElements[0] : (MdlStructs.VertexElement?)null, @@ -987,7 +1099,12 @@ internal static class MdlDecimator foreach (var element in uvList) { var type = (MdlFile.VertexType)element.Type; - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { if (uvChannelCount + 1 > Mesh.UVChannelCount) { @@ -998,7 +1115,11 @@ internal static class MdlDecimator uvElements.Add(new UvElementPacking(element, uvChannelCount, null)); uvChannelCount += 1; } - else if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + else if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { if (uvChannelCount + 2 > Mesh.UVChannelCount) { @@ -1042,6 +1163,15 @@ internal static class MdlDecimator } } + private static Vector3d ReadPositionWithW(BinaryReader reader, out float w) + { + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + w = reader.ReadSingle(); + return new Vector3d(x, y, z); + } + private static Vector3 ReadNormal(MdlFile.VertexType type, BinaryReader reader) { switch (type) @@ -1056,17 +1186,29 @@ internal static class MdlDecimator return new Vector3(x, y, z); case MdlFile.VertexType.NByte4: return ReadNByte4(reader).ToVector3(); + case MdlFile.VertexType.NShort4: + return ReadNShort4(reader).ToVector3(); default: throw new InvalidOperationException($"Unsupported normal type {type}"); } } + private static Vector3 ReadNormalWithW(BinaryReader reader, out float w) + { + var x = reader.ReadSingle(); + var y = reader.ReadSingle(); + var z = reader.ReadSingle(); + w = reader.ReadSingle(); + return new Vector3(x, y, z); + } + private static Vector4 ReadTangent(MdlFile.VertexType type, BinaryReader reader) { return type switch { MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.NByte4 => ReadNByte4(reader), + MdlFile.VertexType.NShort4 => ReadNShort4(reader), _ => throw new InvalidOperationException($"Unsupported tangent type {type}"), }; } @@ -1078,27 +1220,79 @@ internal static class MdlDecimator MdlFile.VertexType.UByte4 => ReadUByte4(reader), MdlFile.VertexType.NByte4 => ReadUByte4(reader), MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short4 => ReadShort4(reader), + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader), _ => throw new InvalidOperationException($"Unsupported color type {type}"), }; } + private static void NormalizeTangents(Vector4[]? tangents, bool clampW) + { + if (tangents == null) + { + return; + } + + for (var i = 0; i < tangents.Length; i++) + { + var tangent = tangents[i]; + var length = MathF.Sqrt(tangent.x * tangent.x + tangent.y * tangent.y + tangent.z * tangent.z); + if (length > 1e-6f) + { + tangent.x /= length; + tangent.y /= length; + tangent.z /= length; + } + + if (clampW) + { + tangent.w = tangent.w >= 0f ? 1f : -1f; + } + + tangents[i] = tangent; + } + } + private static void ReadUv(MdlFile.VertexType type, BinaryReader reader, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex) { - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { - var uv = type == MdlFile.VertexType.Half2 - ? new Vector2(ReadHalf(reader), ReadHalf(reader)) - : new Vector2(reader.ReadSingle(), reader.ReadSingle()); + var uv = type switch + { + MdlFile.VertexType.Half2 => new Vector2(ReadHalf(reader), ReadHalf(reader)), + MdlFile.VertexType.Single2 => new Vector2(reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short2 => ReadShort2(reader), + MdlFile.VertexType.NShort2 => ReadUShort2Normalized(reader), + MdlFile.VertexType.UShort2 => ReadUShort2Normalized(reader), + MdlFile.VertexType.Single1 => new Vector2(reader.ReadSingle(), 0f), + _ => Vector2.zero, + }; uvChannels[mapping.FirstChannel][vertexIndex] = uv; return; } - if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { - var uv = type == MdlFile.VertexType.Half4 - ? new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)) - : new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + var uv = type switch + { + MdlFile.VertexType.Half4 => new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)), + MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), + MdlFile.VertexType.Short4 => ReadShort4(reader), + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader), + _ => Vector4.zero, + }; uvChannels[mapping.FirstChannel][vertexIndex] = new Vector2(uv.x, uv.y); if (mapping.SecondChannel.HasValue) @@ -1108,11 +1302,12 @@ internal static class MdlDecimator } } - private static byte[] ReadIndices(MdlFile.VertexType type, BinaryReader reader) + private static int[] ReadIndices(MdlFile.VertexType type, BinaryReader reader) { return type switch { - MdlFile.VertexType.UByte4 => new[] { reader.ReadByte(), reader.ReadByte(), reader.ReadByte(), reader.ReadByte() }, + MdlFile.VertexType.UByte4 => new[] { (int)reader.ReadByte(), (int)reader.ReadByte(), (int)reader.ReadByte(), (int)reader.ReadByte() }, + MdlFile.VertexType.UShort4 => new[] { (int)reader.ReadUInt16(), (int)reader.ReadUInt16(), (int)reader.ReadUInt16(), (int)reader.ReadUInt16() }, _ => throw new InvalidOperationException($"Unsupported indices type {type}"), }; } @@ -1124,6 +1319,8 @@ internal static class MdlDecimator MdlFile.VertexType.UByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.Single4 => new[] { reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() }, + MdlFile.VertexType.NShort4 => ReadUShort4Normalized(reader).ToFloatArray(), + MdlFile.VertexType.UShort4 => ReadUShort4Normalized(reader).ToFloatArray(), _ => throw new InvalidOperationException($"Unsupported weights type {type}"), }; } @@ -1143,29 +1340,98 @@ internal static class MdlDecimator return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); } - private static Vector4 ReadAndDiscard(MdlFile.VertexType type, BinaryReader reader) + private static Vector2 ReadShort2(BinaryReader reader) + => new(reader.ReadInt16(), reader.ReadInt16()); + + private static Vector4 ReadShort4(BinaryReader reader) + => new(reader.ReadInt16(), reader.ReadInt16(), reader.ReadInt16(), reader.ReadInt16()); + + /* these really don't have a use currently, we don't need to read raw unnormalized ushorts :3 + private static Vector2 ReadUShort2(BinaryReader reader) + => new(reader.ReadUInt16(), reader.ReadUInt16()); + + private static Vector4 ReadUShort4(BinaryReader reader) + => new(reader.ReadUInt16(), reader.ReadUInt16(), reader.ReadUInt16(), reader.ReadUInt16()); + */ + + private static Vector2 ReadUShort2Normalized(BinaryReader reader) + => new(reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue); + + private static Vector4 ReadUShort4Normalized(BinaryReader reader) + => new(reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue, reader.ReadUInt16() / (float)ushort.MaxValue); + + private static Vector4 ReadNShort4(BinaryReader reader) { - return type switch - { - MdlFile.VertexType.Single2 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), 0, 0), - MdlFile.VertexType.Single3 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), 0), - MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), - MdlFile.VertexType.Half2 => new Vector4(ReadHalf(reader), ReadHalf(reader), 0, 0), - MdlFile.VertexType.Half4 => new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)), - MdlFile.VertexType.UByte4 => ReadUByte4(reader), - MdlFile.VertexType.NByte4 => ReadUByte4(reader), - _ => Vector4.zero, - }; + var value = ReadUShort4Normalized(reader); + return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); } - private static void WritePosition(MdlFile.VertexType type, Vector3d value, Span target) + private static Vector4 ReadAndDiscard(MdlFile.VertexType type, BinaryReader reader) { + switch (type) + { + case MdlFile.VertexType.Single1: + return new Vector4(reader.ReadSingle(), 0, 0, 0); + case MdlFile.VertexType.Single2: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), 0, 0); + case MdlFile.VertexType.Single3: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), 0); + case MdlFile.VertexType.Single4: + return new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); + case MdlFile.VertexType.Half2: + return new Vector4(ReadHalf(reader), ReadHalf(reader), 0, 0); + case MdlFile.VertexType.Half4: + return new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)); + case MdlFile.VertexType.UByte4: + return ReadUByte4(reader); + case MdlFile.VertexType.NByte4: + return ReadUByte4(reader); + case MdlFile.VertexType.Short2: + { + var value = ReadShort2(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.Short4: + return ReadShort4(reader); + case MdlFile.VertexType.NShort2: + { + var value = ReadUShort2Normalized(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.NShort4: + return ReadUShort4Normalized(reader); + case MdlFile.VertexType.UShort2: + { + var value = ReadUShort2Normalized(reader); + return new Vector4(value.x, value.y, 0, 0); + } + case MdlFile.VertexType.UShort4: + return ReadUShort4Normalized(reader); + default: + return Vector4.zero; + } + } + + private static void WritePosition(MdlFile.VertexType type, Vector3d value, Span target, float? wOverride = null) + { + if (type == MdlFile.VertexType.Single4 && wOverride.HasValue) + { + WriteVector4(type, new Vector4((float)value.x, (float)value.y, (float)value.z, wOverride.Value), target); + return; + } + WriteVector3(type, new Vector3((float)value.x, (float)value.y, (float)value.z), target); } - private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span target) + private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span target, float? wOverride = null) { - WriteVector3(type, value, target, normalized: type == MdlFile.VertexType.NByte4); + if (type == MdlFile.VertexType.Single4 && wOverride.HasValue) + { + WriteVector4(type, new Vector4(value.x, value.y, value.z, wOverride.Value), target); + return; + } + + WriteVector3(type, value, target, normalized: type == MdlFile.VertexType.NByte4 || type == MdlFile.VertexType.NShort4); } private static void WriteTangent(MdlFile.VertexType type, Vector4 value, Span target) @@ -1176,12 +1442,21 @@ internal static class MdlDecimator return; } + if (type == MdlFile.VertexType.NShort4) + { + WriteNShort4(value, target); + return; + } + WriteVector4(type, value, target); } private static void WriteColor(MdlFile.VertexType type, Vector4 value, Span target) { - if (type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { WriteVector4(type, value, target); return; @@ -1192,28 +1467,40 @@ internal static class MdlDecimator private static void WriteBlendIndices(MdlFile.VertexType type, BoneWeight weights, Span target) { - if (type != MdlFile.VertexType.UByte4) + if (type == MdlFile.VertexType.UByte4) { + target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); + target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); + target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); + target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); return; } - target[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255); - target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); - target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); - target[3] = (byte)Math.Clamp(weights.boneIndex3, 0, 255); + if (type == MdlFile.VertexType.UShort4) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(weights.boneIndex0)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(weights.boneIndex1)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(weights.boneIndex2)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(weights.boneIndex3)); + } } private static void WriteBlendWeights(MdlFile.VertexType type, BoneWeight weights, Span target) { - if (type != MdlFile.VertexType.UByte4 && type != MdlFile.VertexType.NByte4) + if (type == MdlFile.VertexType.Single4) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); + BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); + return; + } + + if (type != MdlFile.VertexType.UByte4 + && type != MdlFile.VertexType.NByte4 + && type != MdlFile.VertexType.UShort4 + && type != MdlFile.VertexType.NShort4) { - if (type == MdlFile.VertexType.Single4) - { - BinaryPrimitives.WriteSingleLittleEndian(target[..4], weights.boneWeight0); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(4, 4), weights.boneWeight1); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); - BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); - } return; } @@ -1223,6 +1510,15 @@ internal static class MdlDecimator var w3 = Clamp01(weights.boneWeight3); NormalizeWeights(ref w0, ref w1, ref w2, ref w3); + if (type == MdlFile.VertexType.UShort4 || type == MdlFile.VertexType.NShort4) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(w0)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(w1)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortNormalized(w2)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortNormalized(w3)); + return; + } + target[0] = ToByte(w0); target[1] = ToByte(w1); target[2] = ToByte(w2); @@ -1231,14 +1527,23 @@ internal static class MdlDecimator private static void WriteUv(MdlFile.VertexType type, UvElementPacking mapping, Vector2[][] uvChannels, int vertexIndex, Span target) { - if (type == MdlFile.VertexType.Half2 || type == MdlFile.VertexType.Single2) + if (type == MdlFile.VertexType.Half2 + || type == MdlFile.VertexType.Single2 + || type == MdlFile.VertexType.Short2 + || type == MdlFile.VertexType.NShort2 + || type == MdlFile.VertexType.UShort2 + || type == MdlFile.VertexType.Single1) { var uv = uvChannels[mapping.FirstChannel][vertexIndex]; WriteVector2(type, uv, target); return; } - if (type == MdlFile.VertexType.Half4 || type == MdlFile.VertexType.Single4) + if (type == MdlFile.VertexType.Half4 + || type == MdlFile.VertexType.Single4 + || type == MdlFile.VertexType.Short4 + || type == MdlFile.VertexType.NShort4 + || type == MdlFile.VertexType.UShort4) { var uv0 = uvChannels[mapping.FirstChannel][vertexIndex]; var uv1 = mapping.SecondChannel.HasValue @@ -1250,6 +1555,12 @@ internal static class MdlDecimator private static void WriteVector2(MdlFile.VertexType type, Vector2 value, Span target) { + if (type == MdlFile.VertexType.Single1) + { + BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); + return; + } + if (type == MdlFile.VertexType.Single2) { BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); @@ -1261,6 +1572,24 @@ internal static class MdlDecimator { WriteHalf(target[..2], value.x); WriteHalf(target.Slice(2, 2), value.y); + return; + } + + if (type == MdlFile.VertexType.Short2) + { + WriteShort2(value, target); + return; + } + + if (type == MdlFile.VertexType.NShort2) + { + WriteUShort2Normalized(value, target); + return; + } + + if (type == MdlFile.VertexType.UShort2) + { + WriteUShort2Normalized(value, target); } } @@ -1286,6 +1615,12 @@ internal static class MdlDecimator if (type == MdlFile.VertexType.NByte4 && normalized) { WriteNByte4(new Vector4(value.x, value.y, value.z, 0f), target); + return; + } + + if (type == MdlFile.VertexType.NShort4 && normalized) + { + WriteNShort4(new Vector4(value.x, value.y, value.z, 0f), target); } } @@ -1308,6 +1643,23 @@ internal static class MdlDecimator WriteHalf(target.Slice(6, 2), value.w); return; } + + if (type == MdlFile.VertexType.Short4) + { + WriteShort4(value, target); + return; + } + + if (type == MdlFile.VertexType.NShort4) + { + WriteUShort4Normalized(value, target); + return; + } + + if (type == MdlFile.VertexType.UShort4) + { + WriteUShort4Normalized(value, target); + } } private static void WriteUByte4(Vector4 value, Span target) @@ -1324,6 +1676,58 @@ internal static class MdlDecimator WriteUByte4(normalized, target); } + private static void WriteShort2(Vector2 value, Span target) + { + BinaryPrimitives.WriteInt16LittleEndian(target[..2], ToShort(value.x)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(2, 2), ToShort(value.y)); + } + + private static void WriteShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteInt16LittleEndian(target[..2], ToShort(value.x)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(2, 2), ToShort(value.y)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(4, 2), ToShort(value.z)); + BinaryPrimitives.WriteInt16LittleEndian(target.Slice(6, 2), ToShort(value.w)); + } + + /* same thing as read here, we don't need to write currently either + private static void WriteUShort2(Vector2 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(value.y)); + } + + private static void WriteUShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShort(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShort(value.w)); + } + */ + + private static void WriteUShort2Normalized(Vector2 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(value.y)); + } + + private static void WriteUShort4Normalized(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortNormalized(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortNormalized(value.w)); + } + + private static void WriteNShort4(Vector4 value, Span target) + { + BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortSnorm(value.x)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortSnorm(value.y)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(4, 2), ToUShortSnorm(value.z)); + BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(6, 2), ToUShortSnorm(value.w)); + } + private static void WriteHalf(Span target, float value) { var half = (Half)value; @@ -1336,9 +1740,32 @@ internal static class MdlDecimator private static float Clamp01(float value) => Math.Clamp(value, 0f, 1f); + private static float ClampMinusOneToOne(float value) + => Math.Clamp(value, -1f, 1f); + private static byte ToByte(float value) => (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); + private static short ToShort(float value) + => (short)Math.Clamp((int)Math.Round(value), short.MinValue, short.MaxValue); + + private static ushort ToUShort(int value) + => (ushort)Math.Clamp(value, ushort.MinValue, ushort.MaxValue); + + /* + private static ushort ToUShort(float value) + => (ushort)Math.Clamp((int)Math.Round(value), ushort.MinValue, ushort.MaxValue); + */ + + private static ushort ToUShortNormalized(float value) + => (ushort)Math.Clamp((int)Math.Round(Clamp01(value) * ushort.MaxValue), ushort.MinValue, ushort.MaxValue); + + private static ushort ToUShortSnorm(float value) + { + var normalized = (ClampMinusOneToOne(value) * 0.5f) + 0.5f; + return ToUShortNormalized(normalized); + } + private static void NormalizeWeights(float[] weights) { var sum = weights.Sum(); @@ -1370,6 +1797,7 @@ internal static class MdlDecimator private static int GetElementSize(MdlFile.VertexType type) => type switch { + MdlFile.VertexType.Single1 => 4, MdlFile.VertexType.Single2 => 8, MdlFile.VertexType.Single3 => 12, MdlFile.VertexType.Single4 => 16, @@ -1377,6 +1805,12 @@ internal static class MdlDecimator MdlFile.VertexType.Half4 => 8, MdlFile.VertexType.UByte4 => 4, MdlFile.VertexType.NByte4 => 4, + MdlFile.VertexType.Short2 => 4, + MdlFile.VertexType.Short4 => 8, + MdlFile.VertexType.NShort2 => 4, + MdlFile.VertexType.NShort4 => 8, + MdlFile.VertexType.UShort2 => 4, + MdlFile.VertexType.UShort4 => 8, _ => throw new InvalidOperationException($"Unsupported vertex type {type}"), }; @@ -1390,8 +1824,10 @@ internal static class MdlDecimator { public VertexFormat( List sortedElements, + MdlStructs.VertexElement positionElement, MdlStructs.VertexElement? normalElement, - MdlStructs.VertexElement? tangentElement, + MdlStructs.VertexElement? tangent1Element, + MdlStructs.VertexElement? tangent2Element, MdlStructs.VertexElement? colorElement, MdlStructs.VertexElement? blendIndicesElement, MdlStructs.VertexElement? blendWeightsElement, @@ -1399,8 +1835,10 @@ internal static class MdlDecimator int uvChannelCount) { SortedElements = sortedElements; + PositionElement = positionElement; NormalElement = normalElement; - TangentElement = tangentElement; + Tangent1Element = tangent1Element; + Tangent2Element = tangent2Element; ColorElement = colorElement; BlendIndicesElement = blendIndicesElement; BlendWeightsElement = blendWeightsElement; @@ -1409,8 +1847,10 @@ internal static class MdlDecimator } public List SortedElements { get; } + public MdlStructs.VertexElement PositionElement { get; } public MdlStructs.VertexElement? NormalElement { get; } - public MdlStructs.VertexElement? TangentElement { get; } + public MdlStructs.VertexElement? Tangent1Element { get; } + public MdlStructs.VertexElement? Tangent2Element { get; } public MdlStructs.VertexElement? ColorElement { get; } public MdlStructs.VertexElement? BlendIndicesElement { get; } public MdlStructs.VertexElement? BlendWeightsElement { get; } @@ -1418,9 +1858,12 @@ internal static class MdlDecimator public int UvChannelCount { get; } public bool HasNormals => NormalElement.HasValue; - public bool HasTangents => TangentElement.HasValue; + public bool HasTangent1 => Tangent1Element.HasValue; + public bool HasTangent2 => Tangent2Element.HasValue; public bool HasColors => ColorElement.HasValue; public bool HasSkinning => BlendIndicesElement.HasValue && BlendWeightsElement.HasValue; + public bool HasPositionW => (MdlFile.VertexType)PositionElement.Type == MdlFile.VertexType.Single4; + public bool HasNormalW => NormalElement.HasValue && (MdlFile.VertexType)NormalElement.Value.Type == MdlFile.VertexType.Single4; } private readonly record struct UvElementPacking(MdlStructs.VertexElement Element, int FirstChannel, int? SecondChannel); @@ -1431,24 +1874,33 @@ internal static class MdlDecimator Vector3d[] positions, Vector3[]? normals, Vector4[]? tangents, + Vector4[]? tangents2, Vector4[]? colors, BoneWeight[]? boneWeights, - Vector2[][]? uvChannels) + Vector2[][]? uvChannels, + float[]? positionWs, + float[]? normalWs) { Positions = positions; Normals = normals; Tangents = tangents; + Tangents2 = tangents2; Colors = colors; BoneWeights = boneWeights; UvChannels = uvChannels; + PositionWs = positionWs; + NormalWs = normalWs; } public Vector3d[] Positions { get; } public Vector3[]? Normals { get; } public Vector4[]? Tangents { get; } + public Vector4[]? Tangents2 { get; } public Vector4[]? Colors { get; } public BoneWeight[]? BoneWeights { get; } public Vector2[][]? UvChannels { get; } + public float[]? PositionWs { get; } + public float[]? NormalWs { get; } } } diff --git a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs index f666805..98f1f88 100644 --- a/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs +++ b/LightlessSync/Services/ModelDecimation/ModelDecimationService.cs @@ -1,5 +1,6 @@ using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; +using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Globalization; @@ -19,7 +20,7 @@ public sealed class ModelDecimationService private readonly XivDataStorageService _xivDataStorageService; private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs); - private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly TaskRegistry _decimationDeduplicator = new(); private readonly ConcurrentDictionary _decimatedPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _failedHashes = new(StringComparer.OrdinalIgnoreCase); @@ -44,14 +45,14 @@ public sealed class ModelDecimationService return; } - if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash)) + if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _)) { return; } _logger.LogInformation("Queued model decimation for {Hash}", hash); - _activeJobs[hash] = Task.Run(async () => + _decimationDeduplicator.GetOrStart(hash, async () => { await _decimationSemaphore.WaitAsync().ConfigureAwait(false); try @@ -66,9 +67,8 @@ public sealed class ModelDecimationService finally { _decimationSemaphore.Release(); - _activeJobs.TryRemove(hash, out _); } - }, CancellationToken.None); + }); } public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null) @@ -116,7 +116,7 @@ public sealed class ModelDecimationService continue; } - if (_activeJobs.TryGetValue(hash, out var job)) + if (_decimationDeduplicator.TryGetExisting(hash, out var job)) { pending.Add(job); } @@ -139,13 +139,18 @@ public sealed class ModelDecimationService return Task.CompletedTask; } - if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio)) + if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio, out var normalizeTangents)) { _logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash); return Task.CompletedTask; } - _logger.LogInformation("Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##})", hash, triangleThreshold, targetRatio); + _logger.LogInformation( + "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents})", + hash, + triangleThreshold, + targetRatio, + normalizeTangents); var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl"); if (File.Exists(destination)) @@ -154,7 +159,7 @@ public sealed class ModelDecimationService return Task.CompletedTask; } - if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger)) + if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, normalizeTangents, _logger)) { _failedHashes[hash] = 1; _logger.LogInformation("Model decimation skipped for {Hash}", hash); @@ -313,10 +318,11 @@ public sealed class ModelDecimationService private static string NormalizeGamePath(string path) => path.Replace('\\', '/').ToLowerInvariant(); - private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio) + private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio, out bool normalizeTangents) { triangleThreshold = 15_000; targetRatio = 0.8; + normalizeTangents = true; var config = _performanceConfigService.Current; if (!config.EnableModelDecimation) @@ -326,6 +332,7 @@ public sealed class ModelDecimationService triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold); targetRatio = config.ModelDecimationTargetRatio; + normalizeTangents = config.ModelDecimationNormalizeTangents; if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio)) { return false; diff --git a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs index c31539f..81b3c52 100644 --- a/LightlessSync/Services/TextureCompression/TextureCompressionService.cs +++ b/LightlessSync/Services/TextureCompression/TextureCompressionService.cs @@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; +using System.Globalization; namespace LightlessSync.Services.TextureCompression; @@ -27,7 +28,9 @@ public sealed class TextureCompressionService public async Task ConvertTexturesAsync( IReadOnlyList requests, IProgress? progress, - CancellationToken token) + CancellationToken token, + bool requestRedraw = true, + bool includeMipMaps = true) { if (requests.Count == 0) { @@ -48,7 +51,7 @@ public sealed class TextureCompressionService continue; } - await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false); + await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false); completed++; } @@ -65,14 +68,16 @@ public sealed class TextureCompressionService int total, int completedBefore, IProgress? progress, - CancellationToken token) + CancellationToken token, + bool requestRedraw, + bool includeMipMaps) { var primaryPath = request.PrimaryFilePath; var displayJob = new TextureConversionJob( primaryPath, primaryPath, targetType, - IncludeMipMaps: true, + IncludeMipMaps: includeMipMaps, request.DuplicateFilePaths); var backupPath = CreateBackupCopy(primaryPath); @@ -83,7 +88,7 @@ public sealed class TextureCompressionService try { WaitForAccess(primaryPath); - await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false); + await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false); if (!IsValidConversionResult(displayJob.OutputFile)) { @@ -128,19 +133,46 @@ public sealed class TextureCompressionService var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray()); foreach (var path in paths) { + var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash); if (!cacheEntries.TryGetValue(path, out var entry) || entry is null) { - entry = _fileCacheManager.CreateFileEntry(path); + if (hasExpectedHash) + { + entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash); + } + + entry ??= _fileCacheManager.CreateFileEntry(path); if (entry is null) { _logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path); continue; } } + else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash); + _fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false); + var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash); + if (corrected is not null) + { + entry = corrected; + } + } try { - _fileCacheManager.UpdateHashedFile(entry); + if (entry.IsCacheEntry) + { + var info = new FileInfo(path); + entry.Size = info.Length; + entry.CompressedSize = null; + entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture); + _fileCacheManager.UpdateHashedFile(entry, computeProperties: false); + } + else + { + _fileCacheManager.UpdateHashedFile(entry); + } } catch (Exception ex) { @@ -149,6 +181,35 @@ public sealed class TextureCompressionService } } + private static bool TryGetExpectedHashFromPath(string path, out string hash) + { + hash = Path.GetFileNameWithoutExtension(path); + if (string.IsNullOrWhiteSpace(hash)) + { + return false; + } + + if (hash.Length is not (40 or 64)) + { + return false; + } + + for (var i = 0; i < hash.Length; i++) + { + var c = hash[i]; + var isHex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!isHex) + { + return false; + } + } + + hash = hash.ToUpperInvariant(); + return true; + } + private static readonly string WorkingDirectory = Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression"); diff --git a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs index 6fa6f92..b5d677c 100644 --- a/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs +++ b/LightlessSync/Services/TextureCompression/TextureDownscaleService.cs @@ -4,9 +4,11 @@ using System.Buffers.Binary; using System.Globalization; using System.IO; using System.Runtime.InteropServices; +using System.Threading; using OtterTex; using OtterImage = OtterTex.Image; using LightlessSync.LightlessConfiguration; +using LightlessSync.Utils; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Lumina.Data.Files; @@ -30,10 +32,12 @@ public sealed class TextureDownscaleService private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly FileCacheManager _fileCacheManager; + private readonly TextureCompressionService _textureCompressionService; - private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); + private readonly TaskRegistry _downscaleDeduplicator = new(); private readonly ConcurrentDictionary _downscaledPaths = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _downscaleSemaphore = new(4); + private readonly SemaphoreSlim _compressionSemaphore = new(1); private static readonly IReadOnlyDictionary BlockCompressedFormatMap = new Dictionary { @@ -68,12 +72,14 @@ public sealed class TextureDownscaleService ILogger logger, LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, - FileCacheManager fileCacheManager) + FileCacheManager fileCacheManager, + TextureCompressionService textureCompressionService) { _logger = logger; _configService = configService; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; + _textureCompressionService = textureCompressionService; } public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) @@ -82,9 +88,9 @@ public sealed class TextureDownscaleService public void ScheduleDownscale(string hash, string filePath, Func mapKindFactory) { if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; - if (_activeJobs.ContainsKey(hash)) return; + if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return; - _activeJobs[hash] = Task.Run(async () => + _downscaleDeduplicator.GetOrStart(hash, async () => { TextureMapKind mapKind; try @@ -98,7 +104,7 @@ public sealed class TextureDownscaleService } await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); - }, CancellationToken.None); + }); } public bool ShouldScheduleDownscale(string filePath) @@ -107,7 +113,9 @@ public sealed class TextureDownscaleService return false; var performanceConfig = _playerPerformanceConfigService.Current; - return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale; + return performanceConfig.EnableNonIndexTextureMipTrim + || performanceConfig.EnableIndexTextureDownscale + || performanceConfig.EnableUncompressedTextureCompression; } public string GetPreferredPath(string hash, string originalPath) @@ -144,7 +152,7 @@ public sealed class TextureDownscaleService continue; } - if (_activeJobs.TryGetValue(hash, out var job)) + if (_downscaleDeduplicator.TryGetExisting(hash, out var job)) { pending.Add(job); } @@ -182,10 +190,18 @@ public sealed class TextureDownscaleService targetMaxDimension = ResolveTargetMaxDimension(); onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures; + if (onlyDownscaleUncompressed && !headerInfo.HasValue) + { + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash); + return; + } + destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex"); if (File.Exists(destination)) { RegisterDownscaledTexture(hash, sourcePath, destination); + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); return; } @@ -196,6 +212,7 @@ public sealed class TextureDownscaleService if (performanceConfig.EnableNonIndexTextureMipTrim && await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false)) { + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); return; } @@ -206,6 +223,7 @@ public sealed class TextureDownscaleService _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -213,6 +231,7 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -222,6 +241,7 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } @@ -229,10 +249,12 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } using var sourceScratch = TexFileHelper.Load(sourcePath); + var sourceFormat = sourceScratch.Meta.Format; using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8; @@ -248,16 +270,39 @@ public sealed class TextureDownscaleService { _downscaledPaths[hash] = sourcePath; _logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); return; } using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple); + var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget); using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height); - using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); + if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra)) + { + if (canReencodeWithPenumbra + && await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false)) + { + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); + return; + } - TexFileHelper.Save(destination, finalScratch); - RegisterDownscaledTexture(hash, sourcePath, destination); + _downscaledPaths[hash] = sourcePath; + _logger.LogTrace( + "Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.", + hash, + sourceFormat); + await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false); + return; + } + + using (finalScratch) + { + TexFileHelper.Save(destination, finalScratch); + RegisterDownscaledTexture(hash, sourcePath, destination); + } + + await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false); } catch (Exception ex) { @@ -277,7 +322,6 @@ public sealed class TextureDownscaleService finally { _downscaleSemaphore.Release(); - _activeJobs.TryRemove(hash, out _); } } @@ -330,6 +374,157 @@ public sealed class TextureDownscaleService } } + private bool TryConvertForSave( + ScratchImage source, + DXGIFormat sourceFormat, + out ScratchImage result, + bool attemptPenumbraFallback) + { + var isCompressed = sourceFormat.IsCompressed(); + var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm; + try + { + result = source.Convert(targetFormat); + return true; + } + catch (Exception ex) + { + var compressedFallback = attemptPenumbraFallback + ? " Attempting Penumbra re-encode." + : " Skipping downscale."; + _logger.LogWarning( + ex, + "Failed to convert downscaled texture to {Format}.{Fallback}", + targetFormat, + isCompressed ? compressedFallback : " Falling back to B8G8R8A8."); + if (isCompressed) + { + result = default!; + return false; + } + + result = source.Convert(DXGIFormat.B8G8R8A8UNorm); + return true; + } + } + + private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target) + { + if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target)) + { + return _textureCompressionService.IsTargetSelectable(target); + } + + if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target)) + { + return _textureCompressionService.IsTargetSelectable(target); + } + + target = default; + return false; + } + + private async Task TryReencodeWithPenumbraAsync( + string hash, + string sourcePath, + string destination, + ScratchImage resizedScratch, + TextureCompressionTarget target) + { + try + { + using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm); + TexFileHelper.Save(destination, uncompressed); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash); + TryDelete(destination); + return false; + } + + await _compressionSemaphore.WaitAsync().ConfigureAwait(false); + try + { + var request = new TextureCompressionRequest(destination, Array.Empty(), target); + await _textureCompressionService + .ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target); + TryDelete(destination); + return false; + } + finally + { + _compressionSemaphore.Release(); + } + + RegisterDownscaledTexture(hash, sourcePath, destination); + _logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination); + return true; + } + + private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo) + { + var performanceConfig = _playerPerformanceConfigService.Current; + if (!performanceConfig.EnableUncompressedTextureCompression) + { + return; + } + + if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath)) + { + return; + } + + var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null); + if (!info.HasValue) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash); + return; + } + + if (IsBlockCompressedFormat(info.Value.Format)) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash); + return; + } + + var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath); + if (suggestion is null) + { + return; + } + + var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target); + if (!_textureCompressionService.IsTargetSelectable(target)) + { + _logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target); + return; + } + + await _compressionSemaphore.WaitAsync().ConfigureAwait(false); + try + { + var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps; + var request = new TextureCompressionRequest(texturePath, Array.Empty(), target); + await _textureCompressionService + .ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath); + } + finally + { + _compressionSemaphore.Release(); + } + } + private static bool IsIndexMap(TextureMapKind kind) => kind is TextureMapKind.Mask or TextureMapKind.Index; diff --git a/LightlessSync/Services/UiService.cs b/LightlessSync/Services/UiService.cs index 16f0f4f..7cc9f9b 100644 --- a/LightlessSync/Services/UiService.cs +++ b/LightlessSync/Services/UiService.cs @@ -13,16 +13,20 @@ namespace LightlessSync.Services; public sealed class UiService : DisposableMediatorSubscriberBase { private readonly List _createdWindows = []; + private readonly List _registeredWindows = []; + private readonly HashSet _uiHiddenWindows = []; private readonly IUiBuilder _uiBuilder; private readonly FileDialogManager _fileDialogManager; private readonly ILogger _logger; private readonly LightlessConfigService _lightlessConfigService; + private readonly DalamudUtilService _dalamudUtilService; private readonly WindowSystem _windowSystem; private readonly UiFactory _uiFactory; private readonly PairFactory _pairFactory; + private bool _uiHideActive; public UiService(ILogger logger, IUiBuilder uiBuilder, - LightlessConfigService lightlessConfigService, WindowSystem windowSystem, + LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem, IEnumerable windows, UiFactory uiFactory, FileDialogManager fileDialogManager, LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator) @@ -31,6 +35,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase _logger.LogTrace("Creating {type}", GetType().Name); _uiBuilder = uiBuilder; _lightlessConfigService = lightlessConfigService; + _dalamudUtilService = dalamudUtilService; _windowSystem = windowSystem; _uiFactory = uiFactory; _pairFactory = pairFactory; @@ -43,6 +48,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase foreach (var window in windows) { + _registeredWindows.Add(window); _windowSystem.AddWindow(window); } @@ -176,6 +182,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase { _windowSystem.RemoveWindow(msg.Window); _createdWindows.Remove(msg.Window); + _registeredWindows.Remove(msg.Window); + _uiHiddenWindows.Remove(msg.Window); msg.Window.Dispose(); }); } @@ -219,12 +227,72 @@ public sealed class UiService : DisposableMediatorSubscriberBase MainStyle.PushStyle(); try { + var hideOtherUi = ShouldHideOtherUi(); + UpdateUiHideState(hideOtherUi); _windowSystem.Draw(); - _fileDialogManager.Draw(); + if (!hideOtherUi) + _fileDialogManager.Draw(); } finally { MainStyle.PopStyle(); } } -} \ No newline at end of file + + private bool ShouldHideOtherUi() + { + var config = _lightlessConfigService.Current; + if (!config.ShowUiWhenUiHidden && _dalamudUtilService.IsGameUiHidden) + return true; + + if (!config.ShowUiInGpose && _dalamudUtilService.IsInGpose) + return true; + + return false; + } + + private void UpdateUiHideState(bool hideOtherUi) + { + if (!hideOtherUi) + { + if (_uiHideActive) + { + foreach (var window in _uiHiddenWindows) + { + window.IsOpen = true; + } + + _uiHiddenWindows.Clear(); + _uiHideActive = false; + } + + return; + } + + _uiHideActive = true; + foreach (var window in EnumerateManagedWindows()) + { + if (window is ZoneChatUi) + continue; + + if (!window.IsOpen) + continue; + + _uiHiddenWindows.Add(window); + window.IsOpen = false; + } + } + + private IEnumerable EnumerateManagedWindows() + { + foreach (var window in _registeredWindows) + { + yield return window; + } + + foreach (var window in _createdWindows) + { + yield return window; + } + } +} diff --git a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs index fe22c85..31c001d 100644 --- a/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs +++ b/LightlessSync/ThirdParty/MeshDecimator/Algorithms/FastQuadricMeshSimplification.cs @@ -239,11 +239,14 @@ namespace MeshDecimator.Algorithms private ResizableArray vertNormals = null; private ResizableArray vertTangents = null; + private ResizableArray vertTangents2 = null; private UVChannels vertUV2D = null; private UVChannels vertUV3D = null; private UVChannels vertUV4D = null; private ResizableArray vertColors = null; private ResizableArray vertBoneWeights = null; + private ResizableArray vertPositionWs = null; + private ResizableArray vertNormalWs = null; private int remainingVertices = 0; @@ -508,10 +511,22 @@ namespace MeshDecimator.Algorithms { vertNormals[i0] = vertNormals[i1]; } + if (vertPositionWs != null) + { + vertPositionWs[i0] = vertPositionWs[i1]; + } + if (vertNormalWs != null) + { + vertNormalWs[i0] = vertNormalWs[i1]; + } if (vertTangents != null) { vertTangents[i0] = vertTangents[i1]; } + if (vertTangents2 != null) + { + vertTangents2[i0] = vertTangents2[i1]; + } if (vertUV2D != null) { for (int i = 0; i < Mesh.UVChannelCount; i++) @@ -561,10 +576,22 @@ namespace MeshDecimator.Algorithms { vertNormals[i0] = (vertNormals[i0] + vertNormals[i1]) * 0.5f; } + if (vertPositionWs != null) + { + vertPositionWs[i0] = (vertPositionWs[i0] + vertPositionWs[i1]) * 0.5f; + } + if (vertNormalWs != null) + { + vertNormalWs[i0] = (vertNormalWs[i0] + vertNormalWs[i1]) * 0.5f; + } if (vertTangents != null) { vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f; } + if (vertTangents2 != null) + { + vertTangents2[i0] = (vertTangents2[i0] + vertTangents2[i1]) * 0.5f; + } if (vertUV2D != null) { for (int i = 0; i < Mesh.UVChannelCount; i++) @@ -1080,11 +1107,14 @@ namespace MeshDecimator.Algorithms var vertNormals = (this.vertNormals != null ? this.vertNormals.Data : null); var vertTangents = (this.vertTangents != null ? this.vertTangents.Data : null); + var vertTangents2 = (this.vertTangents2 != null ? this.vertTangents2.Data : null); var vertUV2D = (this.vertUV2D != null ? this.vertUV2D.Data : null); var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null); var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null); var vertColors = (this.vertColors != null ? this.vertColors.Data : null); var vertBoneWeights = (this.vertBoneWeights != null ? this.vertBoneWeights.Data : null); + var vertPositionWs = (this.vertPositionWs != null ? this.vertPositionWs.Data : null); + var vertNormalWs = (this.vertNormalWs != null ? this.vertNormalWs.Data : null); var triangles = this.triangles.Data; int triangleCount = this.triangles.Length; @@ -1102,6 +1132,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v0 = triangle.va0; } if (triangle.va1 != triangle.v1) @@ -1113,6 +1151,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v1 = triangle.va1; } if (triangle.va2 != triangle.v2) @@ -1124,6 +1170,14 @@ namespace MeshDecimator.Algorithms { vertBoneWeights[iDest] = vertBoneWeights[iSrc]; } + if (vertPositionWs != null) + { + vertPositionWs[iDest] = vertPositionWs[iSrc]; + } + if (vertNormalWs != null) + { + vertNormalWs[iDest] = vertNormalWs[iSrc]; + } triangle.v2 = triangle.va2; } @@ -1153,6 +1207,7 @@ namespace MeshDecimator.Algorithms vertices[dst].p = vert.p; if (vertNormals != null) vertNormals[dst] = vertNormals[i]; if (vertTangents != null) vertTangents[dst] = vertTangents[i]; + if (vertTangents2 != null) vertTangents2[dst] = vertTangents2[i]; if (vertUV2D != null) { for (int j = 0; j < Mesh.UVChannelCount; j++) @@ -1188,6 +1243,8 @@ namespace MeshDecimator.Algorithms } if (vertColors != null) vertColors[dst] = vertColors[i]; if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i]; + if (vertPositionWs != null) vertPositionWs[dst] = vertPositionWs[i]; + if (vertNormalWs != null) vertNormalWs[dst] = vertNormalWs[i]; } ++dst; } @@ -1206,11 +1263,14 @@ namespace MeshDecimator.Algorithms this.vertices.Resize(vertexCount); if (vertNormals != null) this.vertNormals.Resize(vertexCount, true); if (vertTangents != null) this.vertTangents.Resize(vertexCount, true); + if (vertTangents2 != null) this.vertTangents2.Resize(vertexCount, true); if (vertUV2D != null) this.vertUV2D.Resize(vertexCount, true); if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true); if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true); if (vertColors != null) this.vertColors.Resize(vertexCount, true); if (vertBoneWeights != null) this.vertBoneWeights.Resize(vertexCount, true); + if (vertPositionWs != null) this.vertPositionWs.Resize(vertexCount, true); + if (vertNormalWs != null) this.vertNormalWs.Resize(vertexCount, true); } #endregion #endregion @@ -1230,7 +1290,10 @@ namespace MeshDecimator.Algorithms int meshTriangleCount = mesh.TriangleCount; var meshVertices = mesh.Vertices; var meshNormals = mesh.Normals; + var meshPositionWs = mesh.PositionWs; + var meshNormalWs = mesh.NormalWs; var meshTangents = mesh.Tangents; + var meshTangents2 = mesh.Tangents2; var meshColors = mesh.Colors; var meshBoneWeights = mesh.BoneWeights; subMeshCount = meshSubMeshCount; @@ -1260,7 +1323,10 @@ namespace MeshDecimator.Algorithms } vertNormals = InitializeVertexAttribute(meshNormals, "normals"); + vertPositionWs = InitializeVertexAttribute(meshPositionWs, "positionWs"); + vertNormalWs = InitializeVertexAttribute(meshNormalWs, "normalWs"); vertTangents = InitializeVertexAttribute(meshTangents, "tangents"); + vertTangents2 = InitializeVertexAttribute(meshTangents2, "tangents2"); vertColors = InitializeVertexAttribute(meshColors, "colors"); vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights"); @@ -1492,10 +1558,22 @@ namespace MeshDecimator.Algorithms { newMesh.Normals = vertNormals.Data; } + if (vertPositionWs != null) + { + newMesh.PositionWs = vertPositionWs.Data; + } + if (vertNormalWs != null) + { + newMesh.NormalWs = vertNormalWs.Data; + } if (vertTangents != null) { newMesh.Tangents = vertTangents.Data; } + if (vertTangents2 != null) + { + newMesh.Tangents2 = vertTangents2.Data; + } if (vertColors != null) { newMesh.Colors = vertColors.Data; diff --git a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs index 2e38821..416ad4e 100644 --- a/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs +++ b/LightlessSync/ThirdParty/MeshDecimator/Mesh.cs @@ -47,11 +47,14 @@ namespace MeshDecimator private int[][] indices = null; private Vector3[] normals = null; private Vector4[] tangents = null; + private Vector4[] tangents2 = null; private Vector2[][] uvs2D = null; private Vector3[][] uvs3D = null; private Vector4[][] uvs4D = null; private Vector4[] colors = null; private BoneWeight[] boneWeights = null; + private float[] positionWs = null; + private float[] normalWs = null; private static readonly int[] emptyIndices = new int[0]; #endregion @@ -168,6 +171,36 @@ namespace MeshDecimator } } + /// + /// Gets or sets the position W components for this mesh. + /// + public float[] PositionWs + { + get { return positionWs; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The position Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + positionWs = value; + } + } + + /// + /// Gets or sets the normal W components for this mesh. + /// + public float[] NormalWs + { + get { return normalWs; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The normal Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + normalWs = value; + } + } + /// /// Gets or sets the tangents for this mesh. /// @@ -183,6 +216,21 @@ namespace MeshDecimator } } + /// + /// Gets or sets the second tangent set for this mesh. + /// + public Vector4[] Tangents2 + { + get { return tangents2; } + set + { + if (value != null && value.Length != vertices.Length) + throw new ArgumentException(string.Format("The second vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length)); + + tangents2 = value; + } + } + /// /// Gets or sets the first UV set for this mesh. /// @@ -298,11 +346,14 @@ namespace MeshDecimator { normals = null; tangents = null; + tangents2 = null; uvs2D = null; uvs3D = null; uvs4D = null; colors = null; boneWeights = null; + positionWs = null; + normalWs = null; } #endregion diff --git a/LightlessSync/UI/CompactUI.cs b/LightlessSync/UI/CompactUI.cs index a43f228..97763a1 100644 --- a/LightlessSync/UI/CompactUI.cs +++ b/LightlessSync/UI/CompactUI.cs @@ -28,7 +28,6 @@ using System.Collections.Immutable; using System.Globalization; using System.Numerics; using System.Reflection; -using System.Runtime.InteropServices; namespace LightlessSync.UI; @@ -71,6 +70,7 @@ public class CompactUi : WindowMediatorSubscriberBase private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SeluneBrush _seluneBrush = new(); private readonly TopTabMenu _tabMenu; + private readonly OptimizationSummaryCard _optimizationSummaryCard; #endregion @@ -86,7 +86,8 @@ public class CompactUi : WindowMediatorSubscriberBase private int _pendingFocusFrame = -1; private Pair? _pendingFocusPair; private bool _showModalForUserAddition; - private float _transferPartHeight; + private float _footerPartHeight; + private bool _hasFooterPartHeight; private bool _wasOpen; private float _windowContentWidth; @@ -177,6 +178,7 @@ public class CompactUi : WindowMediatorSubscriberBase _characterAnalyzer = characterAnalyzer; _playerPerformanceConfig = playerPerformanceConfig; _lightlessMediator = mediator; + _optimizationSummaryCard = new OptimizationSummaryCard(_uiSharedService, _pairUiService, _playerPerformanceConfig, _fileTransferManager, _lightlessMediator); } #endregion @@ -262,12 +264,17 @@ public class CompactUi : WindowMediatorSubscriberBase using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("pairlist")) DrawPairs(); - var transfersTop = ImGui.GetCursorScreenPos().Y; - var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); + var footerTop = ImGui.GetCursorScreenPos().Y; + var gradientBottom = MathF.Max(gradientTop, footerTop - 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(); + bool drewFooter; + using (ImRaii.PushId("optimization-summary")) + { + drewFooter = _optimizationSummaryCard.Draw(_currentDownloads.Count); + } + _footerPartHeight = drewFooter ? ImGui.GetCursorPosY() - pairlistEnd : 0f; + _hasFooterPartHeight = true; 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(); @@ -330,10 +337,9 @@ public class CompactUi : WindowMediatorSubscriberBase private void DrawPairs() { - float ySize = Math.Abs(_transferPartHeight) < 0.0001f + float ySize = !_hasFooterPartHeight ? 1 - : (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y - + ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY(); + : MathF.Max(1f, ImGui.GetContentRegionAvail().Y - _footerPartHeight); if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false)) { @@ -346,101 +352,6 @@ public class CompactUi : WindowMediatorSubscriberBase ImGui.EndChild(); } - private void DrawTransfers() - { - var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot(); - ImGui.AlignTextToFramePadding(); - _uiSharedService.IconText(FontAwesomeIcon.Upload); - ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - - if (currentUploads.Count > 0) - { - int totalUploads = currentUploads.Count; - int doneUploads = 0; - long totalUploaded = 0; - long totalToUpload = 0; - - foreach (var upload in currentUploads) - { - if (upload.IsTransferred) - { - doneUploads++; - } - - totalUploaded += upload.Transferred; - totalToUpload += upload.Total; - } - - int activeUploads = totalUploads - doneUploads; - var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8); - - ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})"); - var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})"; - var textSize = ImGui.CalcTextSize(uploadText); - ImGui.SameLine(_windowContentWidth - textSize.X); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(uploadText); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("No uploads in progress"); - } - - var downloadSummary = GetDownloadSummary(); - ImGui.AlignTextToFramePadding(); - _uiSharedService.IconText(FontAwesomeIcon.Download); - ImGui.SameLine(35 * ImGuiHelpers.GlobalScale); - - if (downloadSummary.HasDownloads) - { - var totalDownloads = downloadSummary.TotalFiles; - var doneDownloads = downloadSummary.TransferredFiles; - var totalDownloaded = downloadSummary.TransferredBytes; - var totalToDownload = downloadSummary.TotalBytes; - - ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}"); - var downloadText = - $"({UiSharedService.ByteToString(totalDownloaded)}/{UiSharedService.ByteToString(totalToDownload)})"; - var textSize = ImGui.CalcTextSize(downloadText); - ImGui.SameLine(_windowContentWidth - textSize.X); - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted(downloadText); - } - else - { - ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("No downloads in progress"); - } - } - - - private DownloadSummary GetDownloadSummary() - { - long totalBytes = 0; - long transferredBytes = 0; - int totalFiles = 0; - int transferredFiles = 0; - - foreach (var kvp in _currentDownloads.ToArray()) - { - if (kvp.Value is not { Count: > 0 } statuses) - { - continue; - } - - foreach (var status in statuses.Values) - { - totalBytes += status.TotalBytes; - transferredBytes += status.TransferredBytes; - totalFiles += status.TotalFiles; - transferredFiles += status.TransferredFiles; - } - } - - return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes); - } - #endregion #region Header Drawing @@ -1147,13 +1058,4 @@ public class CompactUi : WindowMediatorSubscriberBase #endregion - #region Helper Types - - [StructLayout(LayoutKind.Auto)] - private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes) - { - public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0; - } - - #endregion } diff --git a/LightlessSync/UI/Components/DrawFolderBase.cs b/LightlessSync/UI/Components/DrawFolderBase.cs index 0532da9..39a1b44 100644 --- a/LightlessSync/UI/Components/DrawFolderBase.cs +++ b/LightlessSync/UI/Components/DrawFolderBase.cs @@ -39,7 +39,8 @@ public abstract class DrawFolderBase : IDrawFolder public void Draw() { - if (!RenderIfEmpty && !DrawPairs.Any()) return; + var drawPairCount = DrawPairs.Count; + if (!RenderIfEmpty && drawPairCount == 0) return; _suppressNextRowToggle = false; @@ -111,9 +112,9 @@ public abstract class DrawFolderBase : IDrawFolder if (_tagHandler.IsTagOpen(_id)) { using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false); - if (DrawPairs.Any()) + if (drawPairCount > 0) { - using var clipper = ImUtf8.ListClipper(DrawPairs.Count, ImGui.GetFrameHeightWithSpacing()); + using var clipper = ImUtf8.ListClipper(drawPairCount, ImGui.GetFrameHeightWithSpacing()); while (clipper.Step()) { for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) diff --git a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs index 72063f2..e13106d 100644 --- a/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs +++ b/LightlessSync/UI/Components/DrawGroupedGroupFolder.cs @@ -22,13 +22,16 @@ public class DrawGroupedGroupFolder : IDrawFolder private readonly ApiController _apiController; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi; + private readonly HashSet _onlinePairBuffer = new(StringComparer.Ordinal); + private IImmutableList? _drawPairsCache; + private int? _totalPairsCache; 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(); - public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs); + public IImmutableList DrawPairs => _drawPairsCache ??= _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); + public int OnlinePairs => CountOnlinePairs(DrawPairs); + public int TotalPairs => _totalPairsCache ??= _groups.Sum(g => g.GroupDrawFolder.TotalPairs); public DrawGroupedGroupFolder(IEnumerable groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag) { @@ -50,6 +53,10 @@ public class DrawGroupedGroupFolder : IDrawFolder } using var id = ImRaii.PushId(_id); + var drawPairs = DrawPairs; + var onlinePairs = CountOnlinePairs(drawPairs); + var totalPairs = TotalPairs; + var hasPairs = drawPairs.Count > 0; var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered); var allowRowClick = string.IsNullOrEmpty(_tag); var suppressRowToggle = false; @@ -85,10 +92,10 @@ public class DrawGroupedGroupFolder : IDrawFolder { ImGui.SameLine(); ImGui.AlignTextToFramePadding(); - ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]"); + ImGui.TextUnformatted("[" + onlinePairs.ToString() + "]"); } - UiSharedService.AttachToolTip(OnlinePairs + " online in all of your joined syncshells" + Environment.NewLine + - TotalPairs + " pairs combined in all of your joined syncshells"); + UiSharedService.AttachToolTip(onlinePairs + " online in all of your joined syncshells" + Environment.NewLine + + totalPairs + " pairs combined in all of your joined syncshells"); ImGui.SameLine(); ImGui.AlignTextToFramePadding(); if (_tag != "") @@ -96,7 +103,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.TextUnformatted(_tag); ImGui.SameLine(); - DrawPauseButton(); + DrawPauseButton(hasPairs); ImGui.SameLine(); DrawMenu(ref suppressRowToggle); } else @@ -104,7 +111,7 @@ public class DrawGroupedGroupFolder : IDrawFolder ImGui.TextUnformatted("All Syncshells"); ImGui.SameLine(); - DrawPauseButton(); + DrawPauseButton(hasPairs); } } color.Dispose(); @@ -151,9 +158,9 @@ public class DrawGroupedGroupFolder : IDrawFolder } } - protected void DrawPauseButton() + protected void DrawPauseButton(bool hasPairs) { - if (DrawPairs.Count > 0) + if (hasPairs) { var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused()); FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; @@ -179,6 +186,27 @@ public class DrawGroupedGroupFolder : IDrawFolder } } + private int CountOnlinePairs(IImmutableList drawPairs) + { + if (drawPairs.Count == 0) + { + return 0; + } + + _onlinePairBuffer.Clear(); + foreach (var pair in drawPairs) + { + if (!pair.Pair.IsOnline) + { + continue; + } + + _onlinePairBuffer.Add(pair.Pair.UserData.UID); + } + + return _onlinePairBuffer.Count; + } + protected void ChangePauseStateGroups() { foreach(var group in _groups) diff --git a/LightlessSync/UI/Components/DrawUserPair.cs b/LightlessSync/UI/Components/DrawUserPair.cs index 5524226..3ee10ad 100644 --- a/LightlessSync/UI/Components/DrawUserPair.cs +++ b/LightlessSync/UI/Components/DrawUserPair.cs @@ -340,7 +340,10 @@ public class DrawUserPair ? FontAwesomeIcon.User : FontAwesomeIcon.Users); } - UiSharedService.AttachToolTip(GetUserTooltip()); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip(GetUserTooltip()); + } if (_performanceConfigService.Current.ShowPerformanceIndicator && !_performanceConfigService.Current.UIDsToIgnore @@ -354,22 +357,25 @@ public class DrawUserPair _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); - string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator; - bool shownVram = false; - if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0 - && _performanceConfigService.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < _pair.LastAppliedApproximateVRAMBytes) + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) { - shownVram = true; - userWarningText += $"Approx. VRAM Usage: Used: {UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes)}, Threshold: {_performanceConfigService.Current.VRAMSizeWarningThresholdMiB} MiB"; - } - if (_performanceConfigService.Current.TrisWarningThresholdThousands > 0 - && _performanceConfigService.Current.TrisWarningThresholdThousands * 1024 < _pair.LastAppliedDataTris) - { - if (shownVram) userWarningText += Environment.NewLine; - userWarningText += $"Approx. Triangle count: Used: {_pair.LastAppliedDataTris}, Threshold: {_performanceConfigService.Current.TrisWarningThresholdThousands * 1000}"; - } + string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator; + bool shownVram = false; + if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0 + && _performanceConfigService.Current.VRAMSizeWarningThresholdMiB * 1024 * 1024 < _pair.LastAppliedApproximateVRAMBytes) + { + shownVram = true; + userWarningText += $"Approx. VRAM Usage: Used: {UiSharedService.ByteToString(_pair.LastAppliedApproximateVRAMBytes)}, Threshold: {_performanceConfigService.Current.VRAMSizeWarningThresholdMiB} MiB"; + } + if (_performanceConfigService.Current.TrisWarningThresholdThousands > 0 + && _performanceConfigService.Current.TrisWarningThresholdThousands * 1024 < _pair.LastAppliedDataTris) + { + if (shownVram) userWarningText += Environment.NewLine; + userWarningText += $"Approx. Triangle count: Used: {_pair.LastAppliedDataTris}, Threshold: {_performanceConfigService.Current.TrisWarningThresholdThousands * 1000}"; + } - UiSharedService.AttachToolTip(userWarningText); + UiSharedService.AttachToolTip(userWarningText); + } } ImGui.SameLine(); @@ -613,12 +619,15 @@ public class DrawUserPair perm.SetPaused(!perm.IsPaused()); _ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm)); } - UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() - ? ("Pause pairing with " + _pair.UserData.AliasOrUID - + (_pair.UserPair!.OwnPermissions.IsSticky() - ? string.Empty - : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) - : "Resume pairing with " + _pair.UserData.AliasOrUID); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() + ? ("Pause pairing with " + _pair.UserData.AliasOrUID + + (_pair.UserPair!.OwnPermissions.IsSticky() + ? string.Empty + : UiSharedService.TooltipSeparator + "Hold CTRL to enable preferred permissions while pausing." + Environment.NewLine + "This will leave this pair paused even if unpausing syncshells including this pair.")) + : "Resume pairing with " + _pair.UserData.AliasOrUID); + } if (_pair.IsPaired) { @@ -781,8 +790,11 @@ public class DrawUserPair currentRightSide -= (_uiSharedService.GetIconSize(FontAwesomeIcon.Running).X + (spacingX / 2f)); ImGui.SameLine(currentRightSide); _uiSharedService.IconText(FontAwesomeIcon.Running); - UiSharedService.AttachToolTip($"This user has shared {sharedData.Count} Character Data Sets with you." + UiSharedService.TooltipSeparator - + "Click to open the Character Data Hub and show the entries."); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + UiSharedService.AttachToolTip($"This user has shared {sharedData.Count} Character Data Sets with you." + UiSharedService.TooltipSeparator + + "Click to open the Character Data Hub and show the entries."); + } if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) { _mediator.Publish(new OpenCharaDataHubWithFilterMessage(_pair.UserData)); diff --git a/LightlessSync/UI/Components/OptimizationSettingsPanel.cs b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs new file mode 100644 index 0000000..a75df2d --- /dev/null +++ b/LightlessSync/UI/Components/OptimizationSettingsPanel.cs @@ -0,0 +1,930 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.UI.Services; +using LightlessSync.Utils; +using System.Numerics; + +namespace LightlessSync.UI.Components; + +public enum OptimizationPanelSection +{ + Texture, + Model, +} + +public sealed class OptimizationSettingsPanel +{ + private readonly UiSharedService _uiSharedService; + private readonly PlayerPerformanceConfigService _performanceConfigService; + private readonly PairUiService _pairUiService; + + private const ImGuiTableFlags SettingsTableFlags = ImGuiTableFlags.SizingStretchProp + | ImGuiTableFlags.NoBordersInBody + | ImGuiTableFlags.PadOuterX; + + public OptimizationSettingsPanel( + UiSharedService uiSharedService, + PlayerPerformanceConfigService performanceConfigService, + PairUiService pairUiService) + { + _uiSharedService = uiSharedService; + _performanceConfigService = performanceConfigService; + _pairUiService = pairUiService; + } + + public void DrawSettingsTrees( + string textureLabel, + Vector4 textureColor, + string modelLabel, + Vector4 modelColor, + Func beginTree) + { + if (beginTree(textureLabel, textureColor)) + { + DrawTextureSection(showTitle: false); + UiSharedService.ColoredSeparator(textureColor, 1.5f); + ImGui.TreePop(); + } + + ImGui.Separator(); + + if (beginTree(modelLabel, modelColor)) + { + DrawModelSection(showTitle: false); + UiSharedService.ColoredSeparator(modelColor, 1.5f); + ImGui.TreePop(); + } + } + + public void DrawPopup(OptimizationPanelSection section) + { + switch (section) + { + case OptimizationPanelSection.Texture: + DrawTextureSection(showTitle: false); + break; + case OptimizationPanelSection.Model: + DrawModelSection(showTitle: false); + break; + } + } + + private void DrawTextureSection(bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + DrawSectionIntro( + FontAwesomeIcon.Images, + UIColors.Get("LightlessYellow"), + "Texture Optimization", + "Reduce texture memory by trimming mip levels and downscaling oversized textures.", + showTitle); + + DrawCallout("texture-opt-warning", UIColors.Get("DimRed"), () => + { + _uiSharedService.MediumText("Warning", UIColors.Get("DimRed")); + _uiSharedService.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.")); + + _uiSharedService.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(".")); + + _uiSharedService.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.")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + }); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Core Controls", UIColors.Get("LightlessYellow")); + + var textureConfig = _performanceConfigService.Current; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-core", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Trim mip levels", () => + { + var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim; + var accent = UIColors.Get("LightlessYellow"); + if (DrawAccentCheckbox("##texture-trim-mips", ref trimNonIndex, accent)) + { + textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex; + _performanceConfigService.Save(); + } + }, "Removes high-resolution mip levels from oversized non-index textures.", UIColors.Get("LightlessYellow"), UIColors.Get("LightlessYellow")); + + DrawControlRow("Downscale index textures", () => + { + var downscaleIndex = textureConfig.EnableIndexTextureDownscale; + var accent = UIColors.Get("LightlessYellow"); + if (DrawAccentCheckbox("##texture-downscale-index", ref downscaleIndex, accent)) + { + textureConfig.EnableIndexTextureDownscale = downscaleIndex; + _performanceConfigService.Save(); + } + }, "Downscales oversized index textures to the configured dimension.", UIColors.Get("LightlessYellow"), UIColors.Get("LightlessYellow")); + + DrawControlRow("Max texture dimension", () => + { + 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(-1f); + if (ImGui.Combo("##texture-max-dimension", ref selectedIndex, optionLabels, optionLabels.Length)) + { + textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex]; + _performanceConfigService.Save(); + } + }, "Textures above this size are reduced to the limit. Default: 2048."); + } + } + + if (!textureConfig.EnableNonIndexTextureMipTrim + && !textureConfig.EnableIndexTextureDownscale + && !textureConfig.EnableUncompressedTextureCompression) + { + UiSharedService.ColorTextWrapped( + "Texture trimming, downscale, and compression are disabled. Lightless will keep original textures regardless of size.", + UIColors.Get("DimRed")); + } + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawGroupHeader("Behavior & Exceptions", UIColors.Get("LightlessYellow")); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-behavior", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Only downscale uncompressed", () => + { + var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures; + if (ImGui.Checkbox("##texture-only-uncompressed", ref onlyUncompressed)) + { + textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed; + _performanceConfigService.Save(); + } + }, "When disabled, block-compressed textures can be downscaled too."); + } + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawTextureCompressionCard(textureConfig); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-behavior-extra", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Keep original texture files", () => + { + var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles; + if (ImGui.Checkbox("##texture-keep-original", ref keepOriginalTextures)) + { + textureConfig.KeepOriginalTextureFiles = keepOriginalTextures; + _performanceConfigService.Save(); + } + }, "Keeps the original texture alongside the downscaled copy."); + + DrawControlRow("Skip preferred/direct pairs", () => + { + var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs; + if (ImGui.Checkbox("##texture-skip-preferred", ref skipPreferredDownscale)) + { + textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale; + _performanceConfigService.Save(); + } + }, "Leaves textures untouched for preferred/direct pairs."); + } + } + + UiSharedService.ColorTextWrapped( + "Note: Disabling \"Keep original texture files\" prevents saved/effective VRAM usage information.", + UIColors.Get("LightlessYellow")); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawSummaryPanel("Usage Summary", UIColors.Get("LightlessPurple"), DrawTextureDownscaleCounters); + } + + private void DrawModelSection(bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + DrawSectionIntro( + FontAwesomeIcon.ProjectDiagram, + UIColors.Get("LightlessOrange"), + "Model Optimization", + "Reduce triangle counts by decimating models above a threshold.", + showTitle); + + DrawCallout("model-opt-warning", UIColors.Get("DimRed"), () => + { + _uiSharedService.MediumText("Warning", UIColors.Get("DimRed")); + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Model decimation is a "), + new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances.")); + + _uiSharedService.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(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Runtime decimation "), + new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true), + new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads.")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true)); + }); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawCallout("model-opt-behavior", UIColors.Get("LightlessGreen"), () => + { + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Meshes above the "), + new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(" will be decimated to the "), + new SeStringUtils.RichTextEntry("target ratio", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(". This can reduce quality or alter intended structure.")); + }); + + DrawGroupHeader("Core Controls", UIColors.Get("LightlessOrange")); + var performanceConfig = _performanceConfigService.Current; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-core", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Enable model decimation", () => + { + var enableDecimation = performanceConfig.EnableModelDecimation; + var accent = UIColors.Get("LightlessOrange"); + if (DrawAccentCheckbox("##enable-model-decimation", ref enableDecimation, accent)) + { + performanceConfig.EnableModelDecimation = enableDecimation; + _performanceConfigService.Save(); + } + }, "Generates a decimated copy of models after download.", UIColors.Get("LightlessOrange"), UIColors.Get("LightlessOrange")); + + DrawControlRow("Decimate above (triangles)", () => + { + var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; + ImGui.SetNextItemWidth(-1f); + if (ImGui.SliderInt("##model-decimation-threshold", ref triangleThreshold, 1_000, 100_000)) + { + performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 1_000, 100_000); + _performanceConfigService.Save(); + } + }, "Models below this triangle count are left untouched. Default: 15,000."); + + DrawControlRow("Target triangle ratio", () => + { + var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); + var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); + if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) + { + performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; + _performanceConfigService.Save(); + targetPercent = clampedPercent; + } + + ImGui.SetNextItemWidth(-1f); + if (ImGui.SliderFloat("##model-decimation-target", ref targetPercent, 60f, 99f, "%.0f%%")) + { + performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); + _performanceConfigService.Save(); + } + }, "Ratio relative to original triangle count (80% keeps 80%). Default: 80%."); + } + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Behavior & Exceptions", UIColors.Get("LightlessOrange")); + + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-behavior-table", 3, SettingsTableFlags)) + { + if (table) + { + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Normalize tangents", () => + { + var normalizeTangents = performanceConfig.ModelDecimationNormalizeTangents; + if (ImGui.Checkbox("##model-normalize-tangents", ref normalizeTangents)) + { + performanceConfig.ModelDecimationNormalizeTangents = normalizeTangents; + _performanceConfigService.Save(); + } + }, "Normalizes tangents to reduce shading artifacts."); + + DrawControlRow("Keep original model files", () => + { + var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; + if (ImGui.Checkbox("##model-keep-original", ref keepOriginalModels)) + { + performanceConfig.KeepOriginalModelFiles = keepOriginalModels; + _performanceConfigService.Save(); + } + }, "Keeps the original model alongside the decimated copy."); + + DrawControlRow("Skip preferred/direct pairs", () => + { + var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; + if (ImGui.Checkbox("##model-skip-preferred", ref skipPreferredDecimation)) + { + performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; + _performanceConfigService.Save(); + } + }, "Leaves models untouched for preferred/direct pairs."); + } + } + + UiSharedService.ColorTextWrapped( + "Note: Disabling \"Keep original model files\" prevents saved/effective triangle usage information.", + UIColors.Get("LightlessYellow")); + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + DrawGroupHeader("Decimation Targets", UIColors.Get("LightlessGrey"), "Hair mods are always excluded from decimation."); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), + new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "), + new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), + new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "), + new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true), + new SeStringUtils.RichTextEntry(".")); + + _uiSharedService.DrawNoteLine("!!! ", UIColors.Get("DimRed"), + new SeStringUtils.RichTextEntry("Automatic decimation is not perfect and can cause meshes with bad topology to be worse.", UIColors.Get("DimRed"), true)); + + DrawTargetGrid(performanceConfig); + + ImGui.Dummy(new Vector2(0f, 4f * scale)); + DrawSummaryPanel("Usage Summary", UIColors.Get("LightlessPurple"), DrawTriangleDecimationCounters); + } + + private void DrawTargetGrid(PlayerPerformanceConfig config) + { + var scale = ImGuiHelpers.GlobalScale; + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("model-opt-targets", 3, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthFixed, 180f * scale); + ImGui.TableSetupColumn("Description", ImGuiTableColumnFlags.WidthStretch); + + DrawControlRow("Body", () => + { + var allowBody = config.ModelDecimationAllowBody; + if (ImGui.Checkbox("##model-target-body", ref allowBody)) + { + config.ModelDecimationAllowBody = allowBody; + _performanceConfigService.Save(); + } + }, "Body meshes (torso, limbs)."); + + DrawControlRow("Face/head", () => + { + var allowFaceHead = config.ModelDecimationAllowFaceHead; + if (ImGui.Checkbox("##model-target-facehead", ref allowFaceHead)) + { + config.ModelDecimationAllowFaceHead = allowFaceHead; + _performanceConfigService.Save(); + } + }, "Face and head meshes."); + + DrawControlRow("Tails/Ears", () => + { + var allowTail = config.ModelDecimationAllowTail; + if (ImGui.Checkbox("##model-target-tail", ref allowTail)) + { + config.ModelDecimationAllowTail = allowTail; + _performanceConfigService.Save(); + } + }, "Tail, ear, and similar appendages."); + + DrawControlRow("Clothing", () => + { + var allowClothing = config.ModelDecimationAllowClothing; + if (ImGui.Checkbox("##model-target-clothing", ref allowClothing)) + { + config.ModelDecimationAllowClothing = allowClothing; + _performanceConfigService.Save(); + } + }, "Outfits, shoes, gloves, hats."); + + DrawControlRow("Accessories", () => + { + var allowAccessories = config.ModelDecimationAllowAccessories; + if (ImGui.Checkbox("##model-target-accessories", ref allowAccessories)) + { + config.ModelDecimationAllowAccessories = allowAccessories; + _performanceConfigService.Save(); + } + }, "Jewelry and small add-ons."); + } + } + + private void DrawSectionIntro(FontAwesomeIcon icon, Vector4 color, string title, string subtitle, bool showTitle) + { + var scale = ImGuiHelpers.GlobalScale; + if (showTitle) + { + using (_uiSharedService.MediumFont.Push()) + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(color, title); + } + + ImGui.TextColored(UIColors.Get("LightlessGrey"), subtitle); + } + else + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(UIColors.Get("LightlessGrey"), subtitle); + } + + ImGui.Dummy(new Vector2(0f, 2f * scale)); + } + + private void DrawGroupHeader(string title, Vector4 color, string? helpText = null) + { + using var font = _uiSharedService.MediumFont.Push(); + ImGui.TextColored(color, title); + if (!string.IsNullOrWhiteSpace(helpText)) + { + _uiSharedService.DrawHelpText(helpText); + } + UiSharedService.ColoredSeparator(color, 1.2f); + } + + private void DrawCallout(string id, Vector4 color, Action content) + { + var scale = ImGuiHelpers.GlobalScale; + var bg = new Vector4(color.X, color.Y, color.Z, 0.08f); + var border = new Vector4(color.X, color.Y, color.Z, 0.25f); + DrawPanelBox(id, bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), content); + } + + private void DrawSummaryPanel(string title, Vector4 accent, Action content) + { + var scale = ImGuiHelpers.GlobalScale; + var bg = new Vector4(accent.X, accent.Y, accent.Z, 0.06f); + var border = new Vector4(accent.X, accent.Y, accent.Z, 0.2f); + DrawPanelBox($"summary-{title}", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + _uiSharedService.MediumText(title, accent); + content(); + }); + } + + private void DrawTextureCompressionCard(PlayerPerformanceConfig textureConfig) + { + var scale = ImGuiHelpers.GlobalScale; + var baseColor = UIColors.Get("LightlessGrey"); + var bg = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.12f); + var border = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, 0.32f); + + DrawPanelBox("texture-compression-card", bg, border, 6f * scale, new Vector2(10f * scale, 6f * scale), () => + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, new Vector2(6f * scale, 2f * scale))) + using (var table = ImRaii.Table("texture-opt-compress-card", 2, SettingsTableFlags)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Label", ImGuiTableColumnFlags.WidthFixed, 220f * scale); + ImGui.TableSetupColumn("Control", ImGuiTableColumnFlags.WidthStretch); + + DrawInlineDescriptionRow("Compress uncompressed textures", () => + { + var autoCompress = textureConfig.EnableUncompressedTextureCompression; + if (UiSharedService.CheckboxWithBorder("##texture-auto-compress", ref autoCompress, baseColor)) + { + textureConfig.EnableUncompressedTextureCompression = autoCompress; + _performanceConfigService.Save(); + } + }, "Converts uncompressed textures to BC formats based on map type (heavy). Runs after downscale/mip trim.", + drawLabelSuffix: () => + { + _uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); + UiSharedService.AttachToolTip("This feature can be demanding and will increase character load times."); + }); + + DrawInlineDescriptionRow("Skip mipmaps for auto-compress", () => + { + var skipMipMaps = textureConfig.SkipUncompressedTextureCompressionMipMaps; + if (UiSharedService.CheckboxWithBorder("##texture-auto-compress-skip-mips", ref skipMipMaps, baseColor)) + { + textureConfig.SkipUncompressedTextureCompressionMipMaps = skipMipMaps; + _performanceConfigService.Save(); + } + }, "Skips mipmap generation to speed up compression, but can cause shimmering.", + disableControl: !textureConfig.EnableUncompressedTextureCompression); + } + }); + } + + private void DrawInlineDescriptionRow( + string label, + Action drawControl, + string description, + Action? drawLabelSuffix = null, + bool disableControl = false) + { + var scale = ImGuiHelpers.GlobalScale; + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + + ImGui.TableSetColumnIndex(1); + using (ImRaii.Disabled(disableControl)) + { + drawControl(); + } + + ImGui.SameLine(0f, 8f * scale); + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey"))) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + ImGui.GetContentRegionAvail().X); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + } + } + + private void DrawControlRow(string label, Action drawControl, string description, Vector4? labelColor = null, Vector4? cardAccent = null, Action? drawLabelSuffix = null) + { + var scale = ImGuiHelpers.GlobalScale; + if (!cardAccent.HasValue) + { + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + ImGui.AlignTextToFramePadding(); + using var labelTint = ImRaii.PushColor(ImGuiCol.Text, labelColor ?? Vector4.Zero, labelColor.HasValue); + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + ImGui.TableSetColumnIndex(1); + drawControl(); + ImGui.TableSetColumnIndex(2); + using var color = ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey")); + ImGui.TextWrapped(description); + return; + } + + var padX = 6f * scale; + var padY = 3f * scale; + var rowGap = 4f * scale; + var accent = cardAccent.Value; + var drawList = ImGui.GetWindowDrawList(); + + ImGui.TableNextRow(); + ImGui.TableSetColumnIndex(0); + var col0Start = ImGui.GetCursorScreenPos(); + ImGui.TableSetColumnIndex(1); + var col1Start = ImGui.GetCursorScreenPos(); + ImGui.TableSetColumnIndex(2); + var col2Start = ImGui.GetCursorScreenPos(); + var col2Width = ImGui.GetContentRegionAvail().X; + + float descriptionHeight; + using (ImRaii.PushColor(ImGuiCol.Text, new Vector4(0f, 0f, 0f, 0f))) + { + ImGui.SetCursorScreenPos(col2Start); + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + col2Width); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + descriptionHeight = ImGui.GetItemRectSize().Y; + } + + var lineHeight = ImGui.GetTextLineHeight(); + var labelHeight = lineHeight; + var controlHeight = ImGui.GetFrameHeight(); + var contentHeight = MathF.Max(labelHeight, MathF.Max(controlHeight, descriptionHeight)); + var lineCount = Math.Max(1, (int)MathF.Round(descriptionHeight / MathF.Max(1f, lineHeight))); + var descOffset = lineCount > 1 ? lineHeight * 0.18f : 0f; + var cardTop = col0Start.Y; + var contentTop = cardTop + padY; + var cardHeight = contentHeight + (padY * 2f); + + var labelY = contentTop + (contentHeight - labelHeight) * 0.5f; + var controlY = contentTop + (contentHeight - controlHeight) * 0.5f; + var descY = contentTop + (contentHeight - descriptionHeight) * 0.5f - descOffset; + + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + ImGui.TableSetColumnIndex(0); + ImGui.SetCursorScreenPos(new Vector2(col0Start.X, labelY)); + using (ImRaii.PushColor(ImGuiCol.Text, labelColor ?? Vector4.Zero, labelColor.HasValue)) + { + ImGui.TextUnformatted(label); + if (drawLabelSuffix != null) + { + ImGui.SameLine(0f, 4f * scale); + drawLabelSuffix(); + } + } + + ImGui.TableSetColumnIndex(1); + ImGui.SetCursorScreenPos(new Vector2(col1Start.X, controlY)); + drawControl(); + + ImGui.TableSetColumnIndex(2); + ImGui.SetCursorScreenPos(new Vector2(col2Start.X, descY)); + using (ImRaii.PushColor(ImGuiCol.Text, UIColors.Get("LightlessGrey"))) + { + ImGui.PushTextWrapPos(ImGui.GetCursorPos().X + col2Width); + ImGui.TextUnformatted(description); + ImGui.PopTextWrapPos(); + } + + var rectMin = new Vector2(col0Start.X - padX, cardTop); + var rectMax = new Vector2(col2Start.X + col2Width + padX, cardTop + cardHeight); + var fill = new Vector4(accent.X, accent.Y, accent.Z, 0.07f); + var border = new Vector4(accent.X, accent.Y, accent.Z, 0.35f); + var rounding = MathF.Max(5f, ImGui.GetStyle().FrameRounding) * scale; + var borderThickness = MathF.Max(1f, ImGui.GetStyle().ChildBorderSize); + var clipMin = drawList.GetClipRectMin(); + var clipMax = drawList.GetClipRectMax(); + clipMin.X = MathF.Min(clipMin.X, rectMin.X); + clipMax.X = MathF.Max(clipMax.X, rectMax.X); + + drawList.ChannelsSetCurrent(0); + drawList.PushClipRect(clipMin, clipMax, false); + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(fill), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(border), rounding, ImDrawFlags.None, borderThickness); + drawList.PopClipRect(); + drawList.ChannelsMerge(); + + ImGui.TableSetColumnIndex(2); + ImGui.SetCursorScreenPos(new Vector2(col2Start.X, cardTop + cardHeight)); + ImGui.Dummy(new Vector2(0f, rowGap)); + } + + private static bool DrawAccentCheckbox(string id, ref bool value, Vector4 accent) + { + var frame = new Vector4(accent.X, accent.Y, accent.Z, 0.14f); + var frameHovered = new Vector4(accent.X, accent.Y, accent.Z, 0.22f); + var frameActive = new Vector4(accent.X, accent.Y, accent.Z, 0.3f); + bool changed; + using (ImRaii.PushColor(ImGuiCol.CheckMark, accent)) + using (ImRaii.PushColor(ImGuiCol.FrameBg, frame)) + using (ImRaii.PushColor(ImGuiCol.FrameBgHovered, frameHovered)) + using (ImRaii.PushColor(ImGuiCol.FrameBgActive, frameActive)) + { + changed = ImGui.Checkbox(id, ref value); + } + return changed; + } + + private static void DrawPanelBox(string id, Vector4 background, Vector4 border, float rounding, Vector2 padding, Action content) + { + using (ImRaii.PushId(id)) + { + var startPos = ImGui.GetCursorScreenPos(); + var availableWidth = ImGui.GetContentRegionAvail().X; + var drawList = ImGui.GetWindowDrawList(); + + drawList.ChannelsSplit(2); + drawList.ChannelsSetCurrent(1); + + using (ImRaii.Group()) + { + ImGui.Dummy(new Vector2(0f, padding.Y)); + ImGui.Indent(padding.X); + content(); + ImGui.Unindent(padding.X); + ImGui.Dummy(new Vector2(0f, padding.Y)); + } + + var rectMin = startPos; + var rectMax = new Vector2(startPos.X + availableWidth, ImGui.GetItemRectMax().Y); + var borderThickness = MathF.Max(1f, ImGui.GetStyle().ChildBorderSize); + + drawList.ChannelsSetCurrent(0); + drawList.AddRectFilled(rectMin, rectMax, UiSharedService.Color(background), rounding); + drawList.AddRect(rectMin, rectMax, UiSharedService.Color(border), rounding, ImDrawFlags.None, borderThickness); + drawList.ChannelsMerge(); + } + } + + 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 DrawTriangleDecimationCounters() + { + 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 totalOriginalTris = 0; + long totalEffectiveTris = 0; + var hasData = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + continue; + + var original = pair.LastAppliedDataTris; + var effective = pair.LastAppliedApproximateEffectiveTris; + + if (original >= 0) + { + hasData = true; + totalOriginalTris += original; + } + + if (effective >= 0) + { + hasData = true; + totalEffectiveTris += effective; + } + } + + if (!hasData) + { + ImGui.TextDisabled("Triangle usage has not been calculated yet."); + return; + } + + var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris); + var originalText = FormatTriangleCount(totalOriginalTris); + var effectiveText = FormatTriangleCount(totalEffectiveTris); + var savedText = FormatTriangleCount(savedTris); + + ImGui.TextUnformatted($"Total triangle usage (original): {originalText}"); + ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}"); + + if (savedTris > 0) + { + UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen")); + } + else + { + ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}"); + } + } + + private static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return "n/a"; + } + + 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"; + } +} diff --git a/LightlessSync/UI/Components/OptimizationSummaryCard.cs b/LightlessSync/UI/Components/OptimizationSummaryCard.cs new file mode 100644 index 0000000..62c0bc0 --- /dev/null +++ b/LightlessSync/UI/Components/OptimizationSummaryCard.cs @@ -0,0 +1,789 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; +using LightlessSync.PlayerData.Pairs; +using LightlessSync.Services.Mediator; +using LightlessSync.UI.Services; +using LightlessSync.UI.Style; +using LightlessSync.WebAPI.Files; +using System.Globalization; +using System.Numerics; +using System.Runtime.InteropServices; + +namespace LightlessSync.UI.Components; + +public sealed class OptimizationSummaryCard +{ + private readonly UiSharedService _uiSharedService; + private readonly PairUiService _pairUiService; + private readonly PlayerPerformanceConfigService _playerPerformanceConfig; + private readonly FileUploadManager _fileTransferManager; + private readonly LightlessMediator _lightlessMediator; + private readonly OptimizationSettingsPanel _optimizationSettingsPanel; + private readonly SeluneBrush _optimizationBrush = new(); + private const string OptimizationPopupId = "Optimization Settings##LightlessOptimization"; + private bool _optimizationPopupOpen; + private bool _optimizationPopupRequest; + private OptimizationPanelSection _optimizationPopupSection = OptimizationPanelSection.Texture; + + public OptimizationSummaryCard( + UiSharedService uiSharedService, + PairUiService pairUiService, + PlayerPerformanceConfigService playerPerformanceConfig, + FileUploadManager fileTransferManager, + LightlessMediator lightlessMediator) + { + _uiSharedService = uiSharedService; + _pairUiService = pairUiService; + _playerPerformanceConfig = playerPerformanceConfig; + _fileTransferManager = fileTransferManager; + _lightlessMediator = lightlessMediator; + _optimizationSettingsPanel = new OptimizationSettingsPanel(uiSharedService, playerPerformanceConfig, pairUiService); + } + + public bool Draw(int activeDownloads) + { + var totals = GetPerformanceTotals(); + var scale = ImGuiHelpers.GlobalScale; + var accent = UIColors.Get("LightlessPurple"); + var accentBg = new Vector4(accent.X, accent.Y, accent.Z, 0.04f); + var accentBorder = new Vector4(accent.X, accent.Y, accent.Z, 0.16f); + var summaryPadding = new Vector2(12f * scale, 6f * scale); + var summaryItemSpacing = new Vector2(12f * scale, 4f * scale); + var cellPadding = new Vector2(6f * scale, 2f * scale); + var lineHeight = ImGui.GetFrameHeight(); + var lineSpacing = summaryItemSpacing.Y; + var statsContentHeight = (lineHeight * 2f) + lineSpacing; + var summaryHeight = MathF.Max(56f * scale, statsContentHeight + (summaryPadding.Y * 2f) + (cellPadding.Y * 2f)); + var activeUploads = _fileTransferManager.GetCurrentUploadsSnapshot().Count(upload => !upload.IsTransferred); + + var textureButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Images); + var modelButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.ProjectDiagram); + var buttonWidth = MathF.Max(textureButtonSize.X, modelButtonSize.X); + var performanceConfig = _playerPerformanceConfig.Current; + var textureStatus = GetTextureOptimizationStatus(performanceConfig); + var modelStatus = GetModelOptimizationStatus(performanceConfig); + var textureStatusVisual = GetOptimizationStatusVisual(textureStatus); + var modelStatusVisual = GetOptimizationStatusVisual(modelStatus); + var textureStatusLines = BuildTextureOptimizationStatusLines(performanceConfig); + var modelStatusLines = BuildModelOptimizationStatusLines(performanceConfig); + var statusIconSpacing = 6f * scale; + var statusIconWidth = MathF.Max(GetIconWidth(textureStatusVisual.Icon), GetIconWidth(modelStatusVisual.Icon)); + var buttonRowWidth = buttonWidth + statusIconWidth + statusIconSpacing; + var vramValue = totals.HasVramData + ? UiSharedService.ByteToString(totals.DisplayVramBytes, addSuffix: true) + : "n/a"; + var vramTooltip = BuildVramTooltipData(totals, UIColors.Get("LightlessBlue")); + var triangleValue = totals.HasTriangleData + ? FormatTriangleCount(totals.DisplayTriangleCount) + : "n/a"; + var triangleTooltip = BuildTriangleTooltipData(totals, UIColors.Get("LightlessPurple")); + + var windowPos = ImGui.GetWindowPos(); + var windowSize = ImGui.GetWindowSize(); + var footerTop = ImGui.GetCursorScreenPos().Y; + var gradientTop = MathF.Max(windowPos.Y, footerTop - (12f * scale)); + var gradientBottom = windowPos.Y + windowSize.Y; + var footerSettings = new SeluneGradientSettings + { + GradientColor = UIColors.Get("LightlessPurple"), + GradientPeakOpacity = 0.08f, + GradientPeakPosition = 0.18f, + BackgroundMode = SeluneGradientMode.Vertical, + }; + using var footerSelune = Selune.Begin(_optimizationBrush, ImGui.GetWindowDrawList(), windowPos, windowSize, footerSettings); + footerSelune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); + + using (ImRaii.PushStyle(ImGuiStyleVar.ChildRounding, 6f * scale)) + using (ImRaii.PushStyle(ImGuiStyleVar.ChildBorderSize, MathF.Max(1f, ImGui.GetStyle().ChildBorderSize))) + using (ImRaii.PushStyle(ImGuiStyleVar.WindowPadding, summaryPadding)) + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, summaryItemSpacing)) + using (ImRaii.PushColor(ImGuiCol.ChildBg, UiSharedService.Color(accentBg))) + using (ImRaii.PushColor(ImGuiCol.Border, UiSharedService.Color(accentBorder))) + using (var child = ImRaii.Child("optimizationSummary", new Vector2(-1f, summaryHeight), true, ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)) + { + if (child) + { + using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, cellPadding)) + { + if (ImGui.BeginTable("optimizationSummaryTable", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + ImGui.TableSetupColumn("Stats", ImGuiTableColumnFlags.WidthStretch, 1f); + ImGui.TableSetupColumn("Button", ImGuiTableColumnFlags.WidthFixed, buttonRowWidth + 12f * scale); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + var availableHeight = summaryHeight - (summaryPadding.Y * 2f) - (cellPadding.Y * 2f); + var verticalPad = MathF.Max(0f, (availableHeight - statsContentHeight) * 0.5f); + using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, new Vector2(summaryItemSpacing.X, 0f))) + { + if (verticalPad > 0f) + { + ImGui.Dummy(new Vector2(0f, verticalPad)); + } + DrawOptimizationStatLine(FontAwesomeIcon.Memory, UIColors.Get("LightlessBlue"), "VRAM usage", vramValue, vramTooltip, scale); + if (lineSpacing > 0f) + { + ImGui.Dummy(new Vector2(0f, lineSpacing)); + } + DrawOptimizationStatLine(FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessPurple"), "Triangles", triangleValue, triangleTooltip, scale); + if (verticalPad > 0f) + { + ImGui.Dummy(new Vector2(0f, verticalPad)); + } + } + + ImGui.TableNextColumn(); + var separatorX = ImGui.GetCursorScreenPos().X - cellPadding.X; + var separatorTop = ImGui.GetWindowPos().Y + summaryPadding.Y; + var separatorBottom = ImGui.GetWindowPos().Y + summaryHeight - summaryPadding.Y; + ImGui.GetWindowDrawList().AddLine( + new Vector2(separatorX, separatorTop), + new Vector2(separatorX, separatorBottom), + ImGui.ColorConvertFloat4ToU32(accentBorder), + MathF.Max(1f, 1f * scale)); + float cellWidth = ImGui.GetContentRegionAvail().X; + float offsetX = MathF.Max(0f, cellWidth - buttonRowWidth); + float alignedX = ImGui.GetCursorPosX() + offsetX; + + using (ImRaii.PushStyle(ImGuiStyleVar.FrameRounding, 6f * scale)) + using (ImRaii.PushColor(ImGuiCol.Button, ImGui.ColorConvertFloat4ToU32(new Vector4(0f, 0f, 0f, 0f)))) + { + var buttonBorderThickness = 10f * scale; + var buttonRounding = ImGui.GetStyle().FrameRounding; + + DrawOptimizationStatusButtonRow( + "Texture Optimization", + textureStatusVisual.Icon, + textureStatusVisual.Color, + textureStatusVisual.Label, + textureStatusLines, + FontAwesomeIcon.Images, + textureButtonSize, + "Texture Optimization", + activeUploads, + activeDownloads, + () => OpenOptimizationPopup(OptimizationPanelSection.Texture), + alignedX, + statusIconSpacing, + buttonBorderThickness, + buttonRounding); + + DrawOptimizationStatusButtonRow( + "Model Optimization", + modelStatusVisual.Icon, + modelStatusVisual.Color, + modelStatusVisual.Label, + modelStatusLines, + FontAwesomeIcon.ProjectDiagram, + modelButtonSize, + "Model Optimization", + activeUploads, + activeDownloads, + () => OpenOptimizationPopup(OptimizationPanelSection.Model), + alignedX, + statusIconSpacing, + buttonBorderThickness, + buttonRounding); + } + + ImGui.EndTable(); + } + } + } + } + + footerSelune.DrawHighlightOnly(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); + DrawOptimizationPopup(); + return true; + } + + private PerformanceTotals GetPerformanceTotals() + { + 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 displayVramBytes = 0; + long originalVramBytes = 0; + long effectiveVramBytes = 0; + bool hasVramData = false; + bool hasOriginalVram = false; + bool hasEffectiveVram = false; + + long displayTriangles = 0; + long originalTriangles = 0; + long effectiveTriangles = 0; + bool hasTriangleData = false; + bool hasOriginalTriangles = false; + bool hasEffectiveTriangles = false; + + foreach (var pair in trackedPairs) + { + if (!pair.IsVisible) + { + continue; + } + + var originalVram = pair.LastAppliedApproximateVRAMBytes; + var effectiveVram = pair.LastAppliedApproximateEffectiveVRAMBytes; + + if (originalVram >= 0) + { + originalVramBytes += originalVram; + hasOriginalVram = true; + } + + if (effectiveVram >= 0) + { + effectiveVramBytes += effectiveVram; + hasEffectiveVram = true; + } + + if (effectiveVram >= 0) + { + displayVramBytes += effectiveVram; + hasVramData = true; + } + else if (originalVram >= 0) + { + displayVramBytes += originalVram; + hasVramData = true; + } + + var originalTris = pair.LastAppliedDataTris; + var effectiveTris = pair.LastAppliedApproximateEffectiveTris; + + if (originalTris >= 0) + { + originalTriangles += originalTris; + hasOriginalTriangles = true; + } + + if (effectiveTris >= 0) + { + effectiveTriangles += effectiveTris; + hasEffectiveTriangles = true; + } + + if (effectiveTris >= 0) + { + displayTriangles += effectiveTris; + hasTriangleData = true; + } + else if (originalTris >= 0) + { + displayTriangles += originalTris; + hasTriangleData = true; + } + } + + return new PerformanceTotals( + displayVramBytes, + originalVramBytes, + effectiveVramBytes, + displayTriangles, + originalTriangles, + effectiveTriangles, + hasVramData, + hasOriginalVram, + hasEffectiveVram, + hasTriangleData, + hasOriginalTriangles, + hasEffectiveTriangles); + } + + private void DrawOptimizationStatLine(FontAwesomeIcon icon, Vector4 iconColor, string label, string value, OptimizationStatTooltip? tooltip, float scale) + { + ImGui.AlignTextToFramePadding(); + _uiSharedService.IconText(icon, iconColor); + var hovered = ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextUnformatted($"{label}: {value}"); + hovered |= ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem); + if (hovered && tooltip.HasValue) + { + DrawOptimizationStatTooltip(tooltip.Value); + } + } + + private static OptimizationStatTooltip? BuildVramTooltipData(PerformanceTotals totals, Vector4 titleColor) + { + if (!totals.HasOriginalVram && !totals.HasEffectiveVram) + { + return null; + } + + var lines = new List(); + + if (totals.HasOriginalVram) + { + lines.Add(new OptimizationTooltipLine( + "Original", + UiSharedService.ByteToString(totals.OriginalVramBytes, addSuffix: true), + UIColors.Get("LightlessYellow"))); + } + + if (totals.HasEffectiveVram) + { + lines.Add(new OptimizationTooltipLine( + "Effective", + UiSharedService.ByteToString(totals.EffectiveVramBytes, addSuffix: true), + UIColors.Get("LightlessGreen"))); + } + + if (totals.HasOriginalVram && totals.HasEffectiveVram) + { + var savedBytes = Math.Max(0L, totals.OriginalVramBytes - totals.EffectiveVramBytes); + if (savedBytes > 0) + { + lines.Add(new OptimizationTooltipLine( + "Saved", + UiSharedService.ByteToString(savedBytes, addSuffix: true), + titleColor)); + } + } + + return new OptimizationStatTooltip( + "Total VRAM usage", + "Approximate texture memory across visible users.", + titleColor, + lines); + } + + private static OptimizationStatTooltip? BuildTriangleTooltipData(PerformanceTotals totals, Vector4 titleColor) + { + if (!totals.HasOriginalTriangles && !totals.HasEffectiveTriangles) + { + return null; + } + + var lines = new List(); + + if (totals.HasOriginalTriangles) + { + lines.Add(new OptimizationTooltipLine( + "Original", + $"{FormatTriangleCount(totals.OriginalTriangleCount)} tris", + UIColors.Get("LightlessYellow"))); + } + + if (totals.HasEffectiveTriangles) + { + lines.Add(new OptimizationTooltipLine( + "Effective", + $"{FormatTriangleCount(totals.EffectiveTriangleCount)} tris", + UIColors.Get("LightlessGreen"))); + } + + if (totals.HasOriginalTriangles && totals.HasEffectiveTriangles) + { + var savedTris = Math.Max(0L, totals.OriginalTriangleCount - totals.EffectiveTriangleCount); + if (savedTris > 0) + { + lines.Add(new OptimizationTooltipLine( + "Saved", + $"{FormatTriangleCount(savedTris)} tris", + titleColor)); + } + } + + return new OptimizationStatTooltip( + "Total triangles", + "Approximate triangle count across visible users.", + titleColor, + lines); + } + + private static string FormatTriangleCount(long triangleCount) + { + if (triangleCount < 0) + { + return "n/a"; + } + + if (triangleCount >= 1_000_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m"); + } + + if (triangleCount >= 1_000) + { + return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k"); + } + + return triangleCount.ToString(CultureInfo.InvariantCulture); + } + + private enum OptimizationStatus + { + Off, + Partial, + On, + } + + private static OptimizationStatus GetTextureOptimizationStatus(PlayerPerformanceConfig config) + { + bool trimEnabled = config.EnableNonIndexTextureMipTrim; + bool downscaleEnabled = config.EnableIndexTextureDownscale; + + if (!trimEnabled && !downscaleEnabled) + { + return OptimizationStatus.Off; + } + + return trimEnabled && downscaleEnabled + ? OptimizationStatus.On + : OptimizationStatus.Partial; + } + + private static OptimizationStatus GetModelOptimizationStatus(PlayerPerformanceConfig config) + { + if (!config.EnableModelDecimation) + { + return OptimizationStatus.Off; + } + + bool hasTargets = config.ModelDecimationAllowBody + || config.ModelDecimationAllowFaceHead + || config.ModelDecimationAllowTail + || config.ModelDecimationAllowClothing + || config.ModelDecimationAllowAccessories; + + return hasTargets + ? OptimizationStatus.On + : OptimizationStatus.Partial; + } + + private static (FontAwesomeIcon Icon, Vector4 Color, string Label) GetOptimizationStatusVisual(OptimizationStatus status) + { + return status switch + { + OptimizationStatus.On => (FontAwesomeIcon.Check, UIColors.Get("LightlessGreen"), "Enabled"), + OptimizationStatus.Partial => (FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"), "Partial"), + _ => (FontAwesomeIcon.Times, UIColors.Get("DimRed"), "Disabled"), + }; + } + + private static OptimizationTooltipLine[] BuildTextureOptimizationStatusLines(PlayerPerformanceConfig config) + { + return + [ + new OptimizationTooltipLine("Trim mip levels", FormatOnOff(config.EnableNonIndexTextureMipTrim), GetOnOffColor(config.EnableNonIndexTextureMipTrim)), + new OptimizationTooltipLine("Downscale index textures", FormatOnOff(config.EnableIndexTextureDownscale), GetOnOffColor(config.EnableIndexTextureDownscale)), + new OptimizationTooltipLine("Max dimension", config.TextureDownscaleMaxDimension.ToString(CultureInfo.InvariantCulture)), + new OptimizationTooltipLine("Only downscale uncompressed", FormatOnOff(config.OnlyDownscaleUncompressedTextures), GetOnOffColor(config.OnlyDownscaleUncompressedTextures)), + new OptimizationTooltipLine("Compress uncompressed textures", FormatOnOff(config.EnableUncompressedTextureCompression), GetOnOffColor(config.EnableUncompressedTextureCompression)), + new OptimizationTooltipLine("Skip auto-compress mipmaps", FormatOnOff(config.SkipUncompressedTextureCompressionMipMaps), GetOnOffColor(config.SkipUncompressedTextureCompressionMipMaps)), + new OptimizationTooltipLine("Keep original textures", FormatOnOff(config.KeepOriginalTextureFiles), GetOnOffColor(config.KeepOriginalTextureFiles)), + new OptimizationTooltipLine("Skip preferred pairs", FormatOnOff(config.SkipTextureDownscaleForPreferredPairs), GetOnOffColor(config.SkipTextureDownscaleForPreferredPairs)), + ]; + } + + private static OptimizationTooltipLine[] BuildModelOptimizationStatusLines(PlayerPerformanceConfig config) + { + var targets = new List(); + if (config.ModelDecimationAllowBody) + { + targets.Add("Body"); + } + + if (config.ModelDecimationAllowFaceHead) + { + targets.Add("Face/head"); + } + + if (config.ModelDecimationAllowTail) + { + targets.Add("Tails/Ears"); + } + + if (config.ModelDecimationAllowClothing) + { + targets.Add("Clothing"); + } + + if (config.ModelDecimationAllowAccessories) + { + targets.Add("Accessories"); + } + + var targetLabel = targets.Count > 0 ? string.Join(", ", targets) : "None"; + var targetColor = targets.Count > 0 ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + var threshold = config.ModelDecimationTriangleThreshold.ToString("N0", CultureInfo.InvariantCulture); + var targetRatio = FormatPercent(config.ModelDecimationTargetRatio); + + return + [ + new OptimizationTooltipLine("Decimation enabled", FormatOnOff(config.EnableModelDecimation), GetOnOffColor(config.EnableModelDecimation)), + new OptimizationTooltipLine("Triangle threshold", threshold), + new OptimizationTooltipLine("Target ratio", targetRatio), + new OptimizationTooltipLine("Normalize tangents", FormatOnOff(config.ModelDecimationNormalizeTangents), GetOnOffColor(config.ModelDecimationNormalizeTangents)), + new OptimizationTooltipLine("Keep original models", FormatOnOff(config.KeepOriginalModelFiles), GetOnOffColor(config.KeepOriginalModelFiles)), + new OptimizationTooltipLine("Skip preferred pairs", FormatOnOff(config.SkipModelDecimationForPreferredPairs), GetOnOffColor(config.SkipModelDecimationForPreferredPairs)), + new OptimizationTooltipLine("Targets", targetLabel, targetColor), + ]; + } + + private static string FormatOnOff(bool value) + => value ? "On" : "Off"; + + private static string FormatPercent(double value) + => FormattableString.Invariant($"{value * 100d:0.#}%"); + + private static Vector4 GetOnOffColor(bool value) + => value ? UIColors.Get("LightlessGreen") : UIColors.Get("DimRed"); + + private static float GetIconWidth(FontAwesomeIcon icon) + { + using var iconFont = ImRaii.PushFont(UiBuilder.IconFont); + return ImGui.CalcTextSize(icon.ToIconString()).X; + } + + private readonly record struct OptimizationStatTooltip(string Title, string Description, Vector4 TitleColor, IReadOnlyList Lines); + + private static void DrawOptimizationStatTooltip(OptimizationStatTooltip tooltip) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(tooltip.TitleColor, tooltip.Title); + ImGui.TextColored(UIColors.Get("LightlessGrey"), tooltip.Description); + + foreach (var line in tooltip.Lines) + { + ImGui.TextUnformatted($"{line.Label}:"); + ImGui.SameLine(); + if (line.ValueColor.HasValue) + { + ImGui.TextColored(line.ValueColor.Value, line.Value); + } + else + { + ImGui.TextUnformatted(line.Value); + } + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private static void DrawOptimizationButtonTooltip(string title, int activeUploads, int activeDownloads) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(UIColors.Get("LightlessPurple"), title); + ImGui.TextColored(UIColors.Get("LightlessGrey"), "Open optimization settings."); + + if (activeUploads > 0 || activeDownloads > 0) + { + ImGui.Separator(); + ImGui.TextUnformatted($"Active uploads: {activeUploads}"); + ImGui.TextUnformatted($"Active downloads: {activeDownloads}"); + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private readonly record struct OptimizationTooltipLine(string Label, string Value, Vector4? ValueColor = null); + + private static void DrawOptimizationStatusTooltip(string title, string statusLabel, Vector4 statusColor, IReadOnlyList lines) + { + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 32f); + + ImGui.TextColored(UIColors.Get("LightlessPurple"), title); + ImGui.TextUnformatted("Status:"); + ImGui.SameLine(); + ImGui.TextColored(statusColor, statusLabel); + + foreach (var line in lines) + { + ImGui.TextUnformatted($"{line.Label}:"); + ImGui.SameLine(); + if (line.ValueColor.HasValue) + { + ImGui.TextColored(line.ValueColor.Value, line.Value); + } + else + { + ImGui.TextUnformatted(line.Value); + } + } + + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } + + private void DrawOptimizationStatusButtonRow( + string statusTitle, + FontAwesomeIcon statusIcon, + Vector4 statusColor, + string statusLabel, + IReadOnlyList statusLines, + FontAwesomeIcon buttonIcon, + Vector2 buttonSize, + string tooltipTitle, + int activeUploads, + int activeDownloads, + Action openPopup, + float alignedX, + float iconSpacing, + float buttonBorderThickness, + float buttonRounding) + { + ImGui.SetCursorPosX(alignedX); + ImGui.AlignTextToFramePadding(); + _uiSharedService.IconText(statusIcon, statusColor); + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + { + DrawOptimizationStatusTooltip(statusTitle, statusLabel, statusColor, statusLines); + } + + ImGui.SameLine(0f, iconSpacing); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(buttonIcon.ToIconString(), buttonSize)) + { + openPopup(); + } + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem) || ImGui.IsItemActive()) + { + Selune.RegisterHighlight(ImGui.GetItemRectMin(), ImGui.GetItemRectMax(), SeluneHighlightMode.Both, true, buttonBorderThickness, exactSize: true, clipToElement: true, roundingOverride: buttonRounding); + } + + if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem)) + { + DrawOptimizationButtonTooltip(tooltipTitle, activeUploads, activeDownloads); + } + } + + private void OpenOptimizationPopup(OptimizationPanelSection section) + { + _optimizationPopupSection = section; + _optimizationPopupOpen = true; + _optimizationPopupRequest = true; + } + + private void DrawOptimizationPopup() + { + if (!_optimizationPopupOpen) + { + return; + } + + if (_optimizationPopupRequest) + { + ImGui.OpenPopup(OptimizationPopupId); + _optimizationPopupRequest = false; + } + + var scale = ImGuiHelpers.GlobalScale; + ImGui.SetNextWindowSize(new Vector2(680f * scale, 640f * scale), ImGuiCond.Appearing); + + if (ImGui.BeginPopupModal(OptimizationPopupId, ref _optimizationPopupOpen, UiSharedService.PopupWindowFlags)) + { + DrawOptimizationPopupHeader(); + ImGui.Separator(); + ImGui.Dummy(new Vector2(0f, 4f * scale)); + using (var child = ImRaii.Child("optimization-popup-body", new Vector2(0f, 0f), false, ImGuiWindowFlags.AlwaysVerticalScrollbar)) + { + if (child) + { + _optimizationSettingsPanel.DrawPopup(_optimizationPopupSection); + } + } + + ImGui.EndPopup(); + } + } + + private void DrawOptimizationPopupHeader() + { + var scale = ImGuiHelpers.GlobalScale; + var (title, icon, color, section) = GetPopupHeaderData(_optimizationPopupSection); + var settingsButtonSize = _uiSharedService.GetIconButtonSize(FontAwesomeIcon.Cog); + using (var table = ImRaii.Table("optimization-popup-header", 2, ImGuiTableFlags.SizingStretchProp | ImGuiTableFlags.NoBordersInBody)) + { + if (!table) + { + return; + } + + ImGui.TableSetupColumn("Title", ImGuiTableColumnFlags.WidthStretch); + ImGui.TableSetupColumn("Settings", ImGuiTableColumnFlags.WidthFixed, settingsButtonSize.X); + + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + using (_uiSharedService.MediumFont.Push()) + { + _uiSharedService.IconText(icon, color); + ImGui.SameLine(0f, 6f * scale); + ImGui.TextColored(color, title); + } + + ImGui.TableNextColumn(); + using (ImRaii.PushFont(UiBuilder.IconFont)) + { + if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString(), settingsButtonSize)) + { + OpenOptimizationSettings(section); + } + } + + UiSharedService.AttachToolTip("Open this section in Settings."); + } + } + + private void OpenOptimizationSettings(OptimizationPanelSection section) + { + var target = section == OptimizationPanelSection.Texture + ? PerformanceSettingsSection.TextureOptimization + : PerformanceSettingsSection.ModelOptimization; + _lightlessMediator.Publish(new OpenPerformanceSettingsMessage(target)); + _optimizationPopupOpen = false; + ImGui.CloseCurrentPopup(); + } + + private static (string Title, FontAwesomeIcon Icon, Vector4 Color, OptimizationPanelSection Section) GetPopupHeaderData(OptimizationPanelSection section) + { + return section == OptimizationPanelSection.Texture + ? ("Texture Optimization", FontAwesomeIcon.Images, UIColors.Get("LightlessYellow"), OptimizationPanelSection.Texture) + : ("Model Optimization", FontAwesomeIcon.ProjectDiagram, UIColors.Get("LightlessOrange"), OptimizationPanelSection.Model); + } + + [StructLayout(LayoutKind.Auto)] + private readonly record struct PerformanceTotals( + long DisplayVramBytes, + long OriginalVramBytes, + long EffectiveVramBytes, + long DisplayTriangleCount, + long OriginalTriangleCount, + long EffectiveTriangleCount, + bool HasVramData, + bool HasOriginalVram, + bool HasEffectiveVram, + bool HasTriangleData, + bool HasOriginalTriangles, + bool HasEffectiveTriangles); +} diff --git a/LightlessSync/UI/DownloadUi.cs b/LightlessSync/UI/DownloadUi.cs index 6cc4bd1..e9a9c53 100644 --- a/LightlessSync/UI/DownloadUi.cs +++ b/LightlessSync/UI/DownloadUi.cs @@ -325,16 +325,13 @@ public class DownloadUi : WindowMediatorSubscriberBase if (hasValidSize) { - if (dlProg > 0) - { - fillPercent = transferredBytes / (double)totalBytes; - showFill = true; - } - else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes) + fillPercent = totalBytes > 0 ? transferredBytes / (double)totalBytes : 0.0; + if (isAllComplete && totalBytes > 0) { fillPercent = 1.0; - showFill = true; } + + showFill = transferredBytes > 0 || isAllComplete; } if (showFill) diff --git a/LightlessSync/UI/SettingsUi.cs b/LightlessSync/UI/SettingsUi.cs index 9c2f1ef..bc31556 100644 --- a/LightlessSync/UI/SettingsUi.cs +++ b/LightlessSync/UI/SettingsUi.cs @@ -25,6 +25,7 @@ using LightlessSync.Services.LightFinder; using LightlessSync.Services.Mediator; using LightlessSync.Services.PairProcessing; using LightlessSync.Services.ServerConfiguration; +using LightlessSync.UI.Components; using LightlessSync.UI.Models; using LightlessSync.UI.Services; using LightlessSync.UI.Style; @@ -66,6 +67,7 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly PairUiService _pairUiService; private readonly PerformanceCollectorService _performanceCollector; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; + private readonly OptimizationSettingsPanel _optimizationSettingsPanel; private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly EventAggregator _eventAggregator; private readonly ServerConfigurationManager _serverConfigurationManager; @@ -133,6 +135,12 @@ public class SettingsUi : WindowMediatorSubscriberBase private readonly Dictionary _generalTreeHighlights = new(StringComparer.Ordinal); private const float GeneralTreeHighlightDuration = 1.5f; private readonly SeluneBrush _generalSeluneBrush = new(); + private string? _performanceScrollTarget = null; + private string? _performanceOpenTreeTarget = null; + private const string PerformanceWarningsLabel = "Warnings"; + private const string PerformanceAutoPauseLabel = "Auto Pause"; + private const string PerformanceTextureOptimizationLabel = "Texture Optimization"; + private const string PerformanceModelOptimizationLabel = "Model Optimization"; private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[] { @@ -208,6 +216,7 @@ public class SettingsUi : WindowMediatorSubscriberBase _httpClient = httpClient; _fileCompactor = fileCompactor; _uiShared = uiShared; + _optimizationSettingsPanel = new OptimizationSettingsPanel(_uiShared, _playerPerformanceConfigService, _pairUiService); _nameplateService = nameplateService; _actorObjectService = actorObjectService; _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); @@ -229,6 +238,11 @@ public class SettingsUi : WindowMediatorSubscriberBase _selectGeneralTabOnNextDraw = true; FocusGeneralTree("Lightfinder"); }); + Mediator.Subscribe(this, msg => + { + IsOpen = true; + FocusPerformanceSection(msg.Section); + }); Mediator.Subscribe(this, (_) => IsOpen = false); Mediator.Subscribe(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe(this, (_) => UiSharedService_GposeEnd()); @@ -516,162 +530,6 @@ 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 DrawTriangleDecimationCounters() - { - 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 totalOriginalTris = 0; - long totalEffectiveTris = 0; - var hasData = false; - - foreach (var pair in trackedPairs) - { - if (!pair.IsVisible) - continue; - - var original = pair.LastAppliedDataTris; - var effective = pair.LastAppliedApproximateEffectiveTris; - - if (original >= 0) - { - hasData = true; - totalOriginalTris += original; - } - - if (effective >= 0) - { - hasData = true; - totalEffectiveTris += effective; - } - } - - if (!hasData) - { - ImGui.TextDisabled("Triangle usage has not been calculated yet."); - return; - } - - var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris); - var originalText = FormatTriangleCount(totalOriginalTris); - var effectiveText = FormatTriangleCount(totalEffectiveTris); - var savedText = FormatTriangleCount(savedTris); - - ImGui.TextUnformatted($"Total triangle usage (original): {originalText}"); - ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}"); - - if (savedTris > 0) - { - UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen")); - } - else - { - ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}"); - } - - static string FormatTriangleCount(long triangleCount) - { - if (triangleCount < 0) - { - return "n/a"; - } - - 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"; - } - } - private void DrawThemeVectorRow(MainStyle.StyleVector2Option option) { ImGui.TableNextRow(); @@ -1593,6 +1451,24 @@ public class SettingsUi : WindowMediatorSubscriberBase ImGui.SameLine(); ImGui.TextColored(statusColor, $"[{(pair.IsVisible ? "Visible" : pair.IsOnline ? "Online" : "Offline")}]"); + if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Pair Diagnostics##pairDebugCopy")) + { + ImGui.SetClipboardText(BuildPairDiagnosticsClipboard(pair, snapshot)); + } + + UiSharedService.AttachToolTip("Copies the current pair diagnostics to the clipboard."); + + ImGui.SameLine(); + if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Last Data JSON##pairDebugCopyLastData")) + { + var lastDataForClipboard = pair.LastReceivedCharacterData; + ImGui.SetClipboardText(lastDataForClipboard is null + ? "ERROR: No character data has been received for this pair." + : JsonSerializer.Serialize(lastDataForClipboard, DebugJsonOptions)); + } + + UiSharedService.AttachToolTip("Copies the last received character data JSON to the clipboard."); + if (ImGui.BeginTable("##pairDebugProperties", 2, ImGuiTableFlags.SizingStretchProp)) { DrawPairPropertyRow("UID", pair.UserData.UID); @@ -1722,6 +1598,141 @@ public class SettingsUi : WindowMediatorSubscriberBase DrawPairEventLog(pair); } + private string BuildPairDiagnosticsClipboard(Pair pair, PairUiSnapshot snapshot) + { + var debugInfo = pair.GetDebugInfo(); + StringBuilder sb = new(); + sb.AppendLine("Lightless Pair Diagnostics"); + sb.AppendLine($"Generated: {DateTime.Now.ToString("G", CultureInfo.CurrentCulture)}"); + sb.AppendLine(); + + sb.AppendLine("Pair"); + sb.AppendLine($"Alias/UID: {pair.UserData.AliasOrUID}"); + sb.AppendLine($"UID: {pair.UserData.UID}"); + sb.AppendLine($"Alias: {(string.IsNullOrEmpty(pair.UserData.Alias) ? "(none)" : pair.UserData.Alias)}"); + sb.AppendLine($"Player Name: {pair.PlayerName ?? "(not cached)"}"); + sb.AppendLine($"Handler Ident: {(string.IsNullOrEmpty(pair.Ident) ? "(not bound)" : pair.Ident)}"); + sb.AppendLine($"Character Id: {FormatCharacterId(pair.PlayerCharacterId)}"); + sb.AppendLine($"Direct Pair: {FormatBool(pair.IsDirectlyPaired)}"); + sb.AppendLine($"Individual Status: {pair.IndividualPairStatus}"); + sb.AppendLine($"Any Connection: {FormatBool(pair.HasAnyConnection())}"); + sb.AppendLine($"Paused: {FormatBool(pair.IsPaused)}"); + sb.AppendLine($"Visible: {FormatBool(pair.IsVisible)}"); + sb.AppendLine($"Online: {FormatBool(pair.IsOnline)}"); + sb.AppendLine($"Has Handler: {FormatBool(debugInfo.HasHandler)}"); + sb.AppendLine($"Handler Initialized: {FormatBool(debugInfo.HandlerInitialized)}"); + sb.AppendLine($"Handler Visible: {FormatBool(debugInfo.HandlerVisible)}"); + sb.AppendLine($"Last Time person rendered in: {FormatTimestamp(debugInfo.InvisibleSinceUtc)}"); + sb.AppendLine($"Handler Timer Temp Collection removal: {FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)}"); + sb.AppendLine($"Handler Scheduled For Deletion: {FormatBool(debugInfo.HandlerScheduledForDeletion)}"); + sb.AppendLine($"Note: {pair.GetNote() ?? "(none)"}"); + + sb.AppendLine(); + sb.AppendLine("Applied Data"); + sb.AppendLine($"Last Data Size: {FormatBytes(pair.LastAppliedDataBytes)}"); + sb.AppendLine($"Approx. VRAM: {FormatBytes(pair.LastAppliedApproximateVRAMBytes)}"); + sb.AppendLine($"Effective VRAM: {FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes)}"); + sb.AppendLine($"Last Triangles: {(pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture))}"); + sb.AppendLine($"Effective Triangles: {(pair.LastAppliedApproximateEffectiveTris < 0 ? "n/a" : pair.LastAppliedApproximateEffectiveTris.ToString(CultureInfo.InvariantCulture))}"); + + sb.AppendLine(); + sb.AppendLine("Last Received Character Data"); + var lastData = pair.LastReceivedCharacterData; + if (lastData is null) + { + sb.AppendLine("None"); + } + 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); + sb.AppendLine($"File replacements: {fileReplacementCount} entries across {totalGamePaths} game paths."); + sb.AppendLine($"Customize+: {lastData.CustomizePlusData.Count}, Glamourer entries: {lastData.GlamourerData.Count}"); + sb.AppendLine($"Manipulation length: {lastData.ManipulationData.Length}, Heels set: {FormatBool(!string.IsNullOrEmpty(lastData.HeelsData))}"); + } + + sb.AppendLine(); + sb.AppendLine("Application Timeline"); + sb.AppendLine($"Last Data Received: {FormatTimestamp(debugInfo.LastDataReceivedAt)}"); + sb.AppendLine($"Last Apply Attempt: {FormatTimestamp(debugInfo.LastApplyAttemptAt)}"); + sb.AppendLine($"Last Successful Apply: {FormatTimestamp(debugInfo.LastSuccessfulApplyAt)}"); + + if (!string.IsNullOrEmpty(debugInfo.LastFailureReason)) + { + sb.AppendLine(); + sb.AppendLine($"Last failure: {debugInfo.LastFailureReason}"); + if (debugInfo.BlockingConditions.Count > 0) + { + sb.AppendLine("Blocking conditions:"); + foreach (var condition in debugInfo.BlockingConditions) + { + sb.AppendLine($"- {condition}"); + } + } + } + + sb.AppendLine(); + sb.AppendLine("Application & Download State"); + sb.AppendLine($"Applying Data: {FormatBool(debugInfo.IsApplying)}"); + sb.AppendLine($"Downloading: {FormatBool(debugInfo.IsDownloading)}"); + sb.AppendLine($"Pending Downloads: {debugInfo.PendingDownloadCount.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Forbidden Downloads: {debugInfo.ForbiddenDownloadCount.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Pending Mod Reapply: {FormatBool(debugInfo.PendingModReapply)}"); + sb.AppendLine($"Mod Apply Deferred: {FormatBool(debugInfo.ModApplyDeferred)}"); + sb.AppendLine($"Missing Critical Mods: {debugInfo.MissingCriticalMods.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Missing Non-Critical Mods: {debugInfo.MissingNonCriticalMods.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"Missing Forbidden Mods: {debugInfo.MissingForbiddenMods.ToString(CultureInfo.InvariantCulture)}"); + + sb.AppendLine(); + sb.AppendLine("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))})" + }; + sb.AppendLine($"{group.Group.AliasOrGID} [{group.Group.GID}]{flagLabel}"); + } + } + else + { + sb.AppendLine("Not a member of any syncshells."); + } + + sb.AppendLine(); + sb.AppendLine("Pair DTO Snapshot"); + if (pair.UserPair is null) + { + sb.AppendLine("(unavailable)"); + } + else + { + sb.AppendLine(JsonSerializer.Serialize(pair.UserPair, DebugJsonOptions)); + } + + var relevantEvents = GetRelevantPairEvents(pair, 40); + sb.AppendLine(); + sb.AppendLine("Recent Events"); + if (relevantEvents.Count == 0) + { + sb.AppendLine("No recent events were logged for this pair."); + } + else + { + foreach (var ev in relevantEvents) + { + var timestamp = ev.EventTime.ToString("T", CultureInfo.CurrentCulture); + sb.AppendLine($"{timestamp} [{ev.EventSource}] {ev.EventSeverity}: {ev.Message}"); + } + } + + return sb.ToString(); + } + private static IEnumerable GetGroupInfoFlags(GroupPairUserInfo info) { if (info.HasFlag(GroupPairUserInfo.IsModerator)) @@ -1735,23 +1746,28 @@ public class SettingsUi : WindowMediatorSubscriberBase } } - private void DrawPairEventLog(Pair pair) + private List GetRelevantPairEvents(Pair pair, int maxEvents) { - 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 => + return 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) + .Take(maxEvents) .ToList(); + } + + private void DrawPairEventLog(Pair pair) + { + ImGui.TextUnformatted("Recent Events"); + var relevantEvents = GetRelevantPairEvents(pair, 40); if (relevantEvents.Count == 0) { @@ -2290,11 +2306,29 @@ public class SettingsUi : WindowMediatorSubscriberBase var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; var enableParticleEffects = _configService.Current.EnableParticleEffects; + var showUiWhenUiHidden = _configService.Current.ShowUiWhenUiHidden; + var showUiInGpose = _configService.Current.ShowUiInGpose; using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) { if (behaviorTree.Visible) { + if (ImGui.Checkbox("Show Lightless windows when game UI is hidden", ref showUiWhenUiHidden)) + { + _configService.Current.ShowUiWhenUiHidden = showUiWhenUiHidden; + _configService.Save(); + } + + _uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden when the game UI is hidden."); + + if (ImGui.Checkbox("Show Lightless windows in group pose", ref showUiInGpose)) + { + _configService.Current.ShowUiInGpose = showUiInGpose; + _configService.Save(); + } + + _uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden while in group pose."); + if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects)) { _configService.Current.EnableParticleEffects = enableParticleEffects; @@ -3401,6 +3435,43 @@ public class SettingsUi : WindowMediatorSubscriberBase _generalTreeHighlights[label] = ImGui.GetTime(); } + private void FocusPerformanceSection(PerformanceSettingsSection section) + { + _selectGeneralTabOnNextDraw = false; + _selectedMainTab = MainSettingsTab.Performance; + var label = section switch + { + PerformanceSettingsSection.TextureOptimization => PerformanceTextureOptimizationLabel, + PerformanceSettingsSection.ModelOptimization => PerformanceModelOptimizationLabel, + _ => PerformanceTextureOptimizationLabel, + }; + _performanceOpenTreeTarget = label; + _performanceScrollTarget = label; + } + + private bool BeginPerformanceTree(string label, Vector4 color) + { + var shouldForceOpen = string.Equals(_performanceOpenTreeTarget, label, StringComparison.Ordinal); + if (shouldForceOpen) + { + ImGui.SetNextItemOpen(true, ImGuiCond.Always); + } + + var open = _uiShared.MediumTreeNode(label, color); + if (shouldForceOpen) + { + _performanceOpenTreeTarget = null; + } + + if (open && string.Equals(_performanceScrollTarget, label, StringComparison.Ordinal)) + { + ImGui.SetScrollHereY(0f); + _performanceScrollTarget = null; + } + + return open; + } + private float GetGeneralTreeHighlightAlpha(string label) { if (!_generalTreeHighlights.TryGetValue(label, out var startTime)) @@ -3490,7 +3561,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool showPerformanceIndicator = _playerPerformanceConfigService.Current.ShowPerformanceIndicator; - if (_uiShared.MediumTreeNode("Warnings", UIColors.Get("LightlessPurple"))) + if (BeginPerformanceTree(PerformanceWarningsLabel, UIColors.Get("LightlessPurple"))) { if (ImGui.Checkbox("Show performance indicator", ref showPerformanceIndicator)) { @@ -3586,7 +3657,7 @@ public class SettingsUi : WindowMediatorSubscriberBase bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat; bool autoPauseWhilePerforming = _playerPerformanceConfigService.Current.PauseWhilePerforming; - if (_uiShared.MediumTreeNode("Auto Pause", UIColors.Get("LightlessPurple"))) + if (BeginPerformanceTree(PerformanceAutoPauseLabel, UIColors.Get("LightlessPurple"))) { if (ImGui.Checkbox("Auto pause sync while combat", ref autoPauseInCombat)) { @@ -3683,261 +3754,12 @@ public class SettingsUi : WindowMediatorSubscriberBase 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(selector: 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"))); - - var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs; - if (ImGui.Checkbox("Skip downscale for preferred/direct pairs", ref skipPreferredDownscale)) - { - textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, textures for direct pairs with preferred permissions are left untouched."); - - 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)); - - UiSharedService.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."); - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f); - - ImGui.Dummy(new Vector2(5)); - - DrawTextureDownscaleCounters(); - - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f); - ImGui.TreePop(); - } - - ImGui.Separator(); - - if (_uiShared.MediumTreeNode("Model Optimization", UIColors.Get("DimRed"))) - { - _uiShared.MediumText("Warning", UIColors.Get("DimRed")); - _uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Model decimation is 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 decimation "), - 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)); - - ImGui.Dummy(new Vector2(15)); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), - new SeStringUtils.RichTextEntry("If a mesh exceeds the "), - new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(", it will be decimated automatically to the set "), - new SeStringUtils.RichTextEntry("target triangle ratio", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(". This will reduce quality of the mesh or may break it's intended structure.")); - - - var performanceConfig = _playerPerformanceConfigService.Current; - var enableDecimation = performanceConfig.EnableModelDecimation; - if (ImGui.Checkbox("Enable model decimation", ref enableDecimation)) - { - performanceConfig.EnableModelDecimation = enableDecimation; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, Lightless generates a decimated copy of given model after download."); - - var keepOriginalModels = performanceConfig.KeepOriginalModelFiles; - if (ImGui.Checkbox("Keep original model files", ref keepOriginalModels)) - { - performanceConfig.KeepOriginalModelFiles = keepOriginalModels; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When disabled, Lightless removes the original model after a decimated copy is created."); - ImGui.SameLine(); - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective triangle usage information will not work.", UIColors.Get("LightlessYellow"))); - - var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs; - if (ImGui.Checkbox("Skip decimation for preferred/direct pairs", ref skipPreferredDecimation)) - { - performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation; - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText("When enabled, models for direct pairs with preferred permissions are left untouched."); - - var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold; - ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); - if (ImGui.SliderInt("Decimate models above", ref triangleThreshold, 8_000, 100_000)) - { - performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 8_000, 100_000); - _playerPerformanceConfigService.Save(); - } - ImGui.SameLine(); - ImGui.Text("triangles"); - _uiShared.DrawHelpText($"Models below this triangle count are left untouched.{UiSharedService.TooltipSeparator}Default: 50,000"); - - var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0); - var clampedPercent = Math.Clamp(targetPercent, 60f, 99f); - if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon) - { - performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0; - _playerPerformanceConfigService.Save(); - targetPercent = clampedPercent; - } - ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale); - if (ImGui.SliderFloat("Target triangle ratio", ref targetPercent, 60f, 99f, "%.0f%%")) - { - performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f); - _playerPerformanceConfigService.Save(); - } - _uiShared.DrawHelpText($"Target ratio relative to original triangle count (80% keeps 80% of triangles).{UiSharedService.TooltipSeparator}Default: 80%"); - - ImGui.Dummy(new Vector2(15)); - ImGui.TextUnformatted("Decimation targets"); - _uiShared.DrawHelpText("Hair mods are always excluded from decimation."); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"), - new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "), - new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), - new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "), - new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true), - new SeStringUtils.RichTextEntry(".")); - - _uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"), - new SeStringUtils.RichTextEntry("Remember, automatic decimation is not perfect and can cause meshes to be ruined, especially hair mods.", UIColors.Get("DimRed"), true)); - - var allowBody = performanceConfig.ModelDecimationAllowBody; - if (ImGui.Checkbox("Body", ref allowBody)) - { - performanceConfig.ModelDecimationAllowBody = allowBody; - _playerPerformanceConfigService.Save(); - } - - var allowFaceHead = performanceConfig.ModelDecimationAllowFaceHead; - if (ImGui.Checkbox("Face/head", ref allowFaceHead)) - { - performanceConfig.ModelDecimationAllowFaceHead = allowFaceHead; - _playerPerformanceConfigService.Save(); - } - - var allowTail = performanceConfig.ModelDecimationAllowTail; - if (ImGui.Checkbox("Tails/Ears", ref allowTail)) - { - performanceConfig.ModelDecimationAllowTail = allowTail; - _playerPerformanceConfigService.Save(); - } - - var allowClothing = performanceConfig.ModelDecimationAllowClothing; - if (ImGui.Checkbox("Clothing (body/legs/shoes/gloves/hats)", ref allowClothing)) - { - performanceConfig.ModelDecimationAllowClothing = allowClothing; - _playerPerformanceConfigService.Save(); - } - - var allowAccessories = performanceConfig.ModelDecimationAllowAccessories; - if (ImGui.Checkbox("Accessories (earring/rings/bracelet/necklace)", ref allowAccessories)) - { - performanceConfig.ModelDecimationAllowAccessories = allowAccessories; - _playerPerformanceConfigService.Save(); - } - - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("LightlessGrey"), 3f); - - ImGui.Dummy(new Vector2(5)); - DrawTriangleDecimationCounters(); - ImGui.Dummy(new Vector2(5)); - - UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f); - ImGui.TreePop(); - } + _optimizationSettingsPanel.DrawSettingsTrees( + PerformanceTextureOptimizationLabel, + UIColors.Get("LightlessYellow"), + PerformanceModelOptimizationLabel, + UIColors.Get("LightlessOrange"), + BeginPerformanceTree); ImGui.Separator(); ImGui.Dummy(new Vector2(10)); diff --git a/LightlessSync/UI/Style/MainStyle.cs b/LightlessSync/UI/Style/MainStyle.cs index 53dd682..132dc2c 100644 --- a/LightlessSync/UI/Style/MainStyle.cs +++ b/LightlessSync/UI/Style/MainStyle.cs @@ -40,10 +40,10 @@ internal static class MainStyle new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), 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(22, 14, 41, 255), ImGuiCol.TitleBg), - new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), - new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), - + 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), + new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed), + new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarGrab", "Scrollbar Grab", () => Rgba(62, 62, 62, 255), ImGuiCol.ScrollbarGrab), diff --git a/LightlessSync/UI/Style/Selune.cs b/LightlessSync/UI/Style/Selune.cs index f89a1f0..00843a8 100644 --- a/LightlessSync/UI/Style/Selune.cs +++ b/LightlessSync/UI/Style/Selune.cs @@ -29,6 +29,7 @@ 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 GradientPeakPosition { get; init; } = 0.035f; public float HighlightPeakAlpha { get; init; } = 0.13f; public float HighlightEdgeAlpha { get; init; } = 0f; public float HighlightMidpoint { get; init; } = 0.45f; @@ -378,6 +379,7 @@ internal static class SeluneRenderer topColorVec, midColorVec, bottomColorVec, + settings, settings.BackgroundMode); } @@ -403,19 +405,21 @@ internal static class SeluneRenderer Vector4 topColorVec, Vector4 midColorVec, Vector4 bottomColorVec, + SeluneGradientSettings settings, SeluneGradientMode mode) { + var peakPosition = Math.Clamp(settings.GradientPeakPosition, 0.01f, 0.99f); switch (mode) { case SeluneGradientMode.Vertical: - DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; case SeluneGradientMode.Horizontal: - DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; case SeluneGradientMode.Both: - DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); - DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); + DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); + DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition); break; } } @@ -428,13 +432,14 @@ internal static class SeluneRenderer float clampedBottomY, Vector4 topColorVec, Vector4 midColorVec, - Vector4 bottomColorVec) + Vector4 bottomColorVec, + float peakPosition) { var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec); - var midY = clampedTopY + (clampedBottomY - clampedTopY) * 0.035f; + var midY = clampedTopY + (clampedBottomY - clampedTopY) * peakPosition; drawList.AddRectFilledMultiColor( new Vector2(gradientLeft, clampedTopY), new Vector2(gradientRight, midY), @@ -460,13 +465,14 @@ internal static class SeluneRenderer float clampedBottomY, Vector4 leftColorVec, Vector4 midColorVec, - Vector4 rightColorVec) + Vector4 rightColorVec, + float peakPosition) { var leftColor = ImGui.ColorConvertFloat4ToU32(leftColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var rightColor = ImGui.ColorConvertFloat4ToU32(rightColorVec); - var midX = gradientLeft + (gradientRight - gradientLeft) * 0.035f; + var midX = gradientLeft + (gradientRight - gradientLeft) * peakPosition; drawList.AddRectFilledMultiColor( new Vector2(gradientLeft, clampedTopY), new Vector2(midX, clampedBottomY), diff --git a/LightlessSync/UI/ZoneChatUi.cs b/LightlessSync/UI/ZoneChatUi.cs index 571b8ca..8f799de 100644 --- a/LightlessSync/UI/ZoneChatUi.cs +++ b/LightlessSync/UI/ZoneChatUi.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Numerics; +using System.Reflection; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.API.Data.Enum; @@ -8,9 +9,11 @@ using Dalamud.Interface; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; using LightlessSync.API.Dto.Chat; using LightlessSync.API.Dto.Group; using LightlessSync.LightlessConfiguration; +using LightlessSync.LightlessConfiguration.Configurations; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.Services; using LightlessSync.Services.Chat; @@ -38,6 +41,7 @@ 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 string EmotePickerPopupId = "zone_chat_emote_picker"; + private const string MentionPopupId = "zone_chat_mention_popup"; private const int EmotePickerColumns = 10; private const float DefaultWindowOpacity = .97f; private const float DefaultUnfocusedWindowOpacity = 0.6f; @@ -45,11 +49,37 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private const float MaxWindowOpacity = 1f; private const float MinChatFontScale = 0.75f; private const float MaxChatFontScale = 1.5f; + private const float MinEmoteScale = 0.5f; + private const float MaxEmoteScale = 2.0f; 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; + private const int MaxBadgeDisplay = 99; + private const int MaxMentionSuggestions = 8; + private const int CollapsedMessageCountDisplayCap = 999; + + private static readonly FieldInfo? FadeOutOriginField = typeof(Window).GetField("fadeOutOrigin", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly FieldInfo? FadeOutSizeField = typeof(Window).GetField("fadeOutSize", BindingFlags.Instance | BindingFlags.NonPublic); + + private enum ChatSettingsTab + { + General, + Messages, + Notifications, + Visibility, + Window + } + + private static readonly UiSharedService.TabOption[] ChatSettingsTabOptions = + [ + new UiSharedService.TabOption("General", ChatSettingsTab.General), + new UiSharedService.TabOption("Messages", ChatSettingsTab.Messages), + new UiSharedService.TabOption("Notifications", ChatSettingsTab.Notifications), + new UiSharedService.TabOption("Visibility", ChatSettingsTab.Visibility), + new UiSharedService.TabOption("Window", ChatSettingsTab.Window), + ]; private readonly UiSharedService _uiSharedService; private readonly ZoneChatService _zoneChatService; @@ -66,6 +96,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private readonly Dictionary _draftMessages = new(StringComparer.Ordinal); private readonly Dictionary> _pendingDraftClears = new(StringComparer.Ordinal); private readonly ImGuiWindowFlags _unpinnedWindowFlags; + private string? _activeInputChannelKey; + private int _pendingDraftCursorPos = -1; + private string? _pendingDraftCursorChannelKey; private float _currentWindowOpacity = DefaultWindowOpacity; private float _baseWindowOpacity = DefaultWindowOpacity; private bool _isWindowPinned; @@ -94,9 +127,19 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private string? _dragHoverKey; private bool _openEmotePicker; private string _emoteFilter = string.Empty; + private int _mentionSelectionIndex = -1; + private string? _mentionSelectionKey; private bool _HideStateActive; private bool _HideStateWasOpen; private bool _pushedStyle; + private ChatSettingsTab _selectedChatSettingsTab = ChatSettingsTab.General; + private bool _isWindowCollapsed; + private bool _wasWindowCollapsed; + private int _collapsedMessageCount; + private bool _forceExpandOnOpen; + private Vector2 _lastWindowPos; + private Vector2 _lastWindowSize; + private bool _hasWindowMetrics; public ZoneChatUi( ILogger logger, @@ -158,7 +201,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var config = _chatConfigService.Current; var baseOpacity = Math.Clamp(config.ChatWindowOpacity, MinWindowOpacity, MaxWindowOpacity); _baseWindowOpacity = baseOpacity; - ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0); + ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 1f); _pushedStyle = true; if (config.FadeWhenUnfocused) @@ -245,11 +288,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase 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; @@ -263,14 +301,20 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var drawList = ImGui.GetWindowDrawList(); var windowPos = ImGui.GetWindowPos(); var windowSize = ImGui.GetWindowSize(); + _lastWindowPos = windowPos; + _lastWindowSize = windowSize; + _hasWindowMetrics = true; + UpdateCollapsedState(isCollapsed: false); using var selune = Selune.Begin(_seluneBrush, drawList, windowPos, windowSize); var childBgColor = ImGui.GetStyle().Colors[(int)ImGuiCol.ChildBg]; childBgColor.W *= _baseWindowOpacity; using var childBg = ImRaii.PushColor(ImGuiCol.ChildBg, childBgColor); DrawConnectionControls(); - var channels = _zoneChatService.GetChannelsSnapshot(); + IReadOnlyList channels = _zoneChatService.GetChannelsSnapshot(); + IReadOnlyList visibleChannels = GetVisibleChannels(channels); DrawReportPopup(); + CleanupDrafts(channels); if (channels.Count == 0) { @@ -278,12 +322,18 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.TextWrapped("No chat channels available."); ImGui.PopStyleColor(); } + else if (visibleChannels.Count == 0) + { + EnsureSelectedChannel(visibleChannels); + ImGui.PushStyleColor(ImGuiCol.Text, ImGuiColors.DalamudGrey3); + ImGui.TextWrapped("All chat channels are hidden. Open chat settings to show channels."); + ImGui.PopStyleColor(); + } else { - EnsureSelectedChannel(channels); - CleanupDrafts(channels); + EnsureSelectedChannel(visibleChannels); - DrawChannelButtons(channels); + DrawChannelButtons(visibleChannels); if (_selectedChannelKey is null) { @@ -291,10 +341,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return; } - var activeChannel = channels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); + ChatChannelSnapshot activeChannel = visibleChannels.FirstOrDefault(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal)); if (activeChannel.Equals(default(ChatChannelSnapshot))) { - activeChannel = channels[0]; + activeChannel = visibleChannels[0]; _selectedChannelKey = activeChannel.Key; } @@ -331,6 +381,136 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _titleBarStylePopCount = 3; } + private void DrawCollapsedMessageBadge(ImDrawListPtr drawList, Vector2 windowPos, Vector2 windowSize) + { + if (_collapsedMessageCount <= 0) + { + return; + } + + var style = ImGui.GetStyle(); + var titleBarHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f; + var scale = ImGuiHelpers.GlobalScale; + + var displayCount = _collapsedMessageCount > CollapsedMessageCountDisplayCap + ? $"{CollapsedMessageCountDisplayCap}+" + : _collapsedMessageCount.ToString(CultureInfo.InvariantCulture); + var padding = new Vector2(8f, 3f) * scale; + + var title = WindowName ?? string.Empty; + var titleSplitIndex = title.IndexOf("###", StringComparison.Ordinal); + if (titleSplitIndex >= 0) + { + title = title[..titleSplitIndex]; + } + var titleSize = ImGui.CalcTextSize(title); + var leftEdge = windowPos.X + style.FramePadding.X + titleSize.X + style.ItemInnerSpacing.X + 6f * scale; + + var buttonCount = GetTitleBarButtonCount(); + var buttonWidth = ImGui.GetFrameHeight(); + var buttonSpacing = style.ItemInnerSpacing.X; + var buttonArea = buttonCount > 0 + ? (buttonWidth * buttonCount) + (buttonSpacing * (buttonCount - 1)) + : 0f; + var rightEdge = windowPos.X + windowSize.X - style.FramePadding.X - buttonArea; + var availableWidth = rightEdge - leftEdge; + if (availableWidth <= 0f) + { + return; + } + + string label = $"New messages: {displayCount}"; + var textSize = ImGui.CalcTextSize(label); + var badgeSize = textSize + padding * 2f; + if (badgeSize.X > availableWidth) + { + label = $"New: {displayCount}"; + textSize = ImGui.CalcTextSize(label); + badgeSize = textSize + padding * 2f; + } + if (badgeSize.X > availableWidth) + { + label = displayCount; + textSize = ImGui.CalcTextSize(label); + badgeSize = textSize + padding * 2f; + } + if (badgeSize.X > availableWidth) + { + return; + } + + var posX = MathF.Max(leftEdge, rightEdge - badgeSize.X); + var posY = windowPos.Y + (titleBarHeight - badgeSize.Y) * 0.5f; + var badgeMin = new Vector2(posX, posY); + var badgeMax = badgeMin + badgeSize; + + var time = (float)ImGui.GetTime(); + var pulse = 0.6f + 0.2f * (1f + MathF.Sin(time * 2f)); + var baseColor = UIColors.Get("DimRed"); + var fillColor = new Vector4(baseColor.X, baseColor.Y, baseColor.Z, baseColor.W * pulse); + drawList.AddRectFilled(badgeMin, badgeMax, ImGui.ColorConvertFloat4ToU32(fillColor), 6f * scale); + drawList.AddText(badgeMin + padding, ImGui.ColorConvertFloat4ToU32(ImGuiColors.DalamudWhite), label); + } + + private int GetTitleBarButtonCount() + { + var count = 0; + if (!Flags.HasFlag(ImGuiWindowFlags.NoCollapse)) + { + count++; + } + + if (ShowCloseButton) + { + count++; + } + + if (AllowPinning || AllowClickthrough) + { + count++; + } + + count += TitleBarButtons?.Count ?? 0; + return count; + } + + private void UpdateCollapsedState(bool isCollapsed) + { + if (isCollapsed != _wasWindowCollapsed) + { + _collapsedMessageCount = 0; + _wasWindowCollapsed = isCollapsed; + } + + _isWindowCollapsed = isCollapsed; + } + + private bool TryUpdateWindowMetricsFromBase() + { + if (FadeOutOriginField is null || FadeOutSizeField is null) + { + return false; + } + + if (FadeOutOriginField.GetValue(this) is Vector2 pos && FadeOutSizeField.GetValue(this) is Vector2 size) + { + _lastWindowPos = pos; + _lastWindowSize = size; + _hasWindowMetrics = true; + return true; + } + + return false; + } + + private static bool IsLikelyCollapsed(Vector2 windowSize) + { + var style = ImGui.GetStyle(); + var titleHeight = ImGui.GetFontSize() + style.FramePadding.Y * 2f; + var threshold = titleHeight + style.WindowBorderSize * 2f + 2f * ImGuiHelpers.GlobalScale; + return windowSize.Y <= threshold; + } + private void DrawHeader(ChatChannelSnapshot channel) { var prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; @@ -418,6 +598,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase var showTimestamps = _chatConfigService.Current.ShowMessageTimestamps; _chatEmoteService.EnsureGlobalEmotesLoaded(); PairUiSnapshot? pairSnapshot = null; + MentionHighlightData? mentionHighlightData = null; var itemSpacing = ImGui.GetStyle().ItemSpacing.X; if (channel.Messages.Count == 0) @@ -428,6 +609,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { + if (channel.Type == ChatChannelType.Group) + { + pairSnapshot ??= _pairUiService.GetSnapshot(); + mentionHighlightData = BuildMentionHighlightData(channel, pairSnapshot); + } + var messageCount = channel.Messages.Count; var contentMaxX = ImGui.GetWindowContentRegionMax().X; var cursorStartX = ImGui.GetCursorPosX(); @@ -437,7 +624,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase for (var i = 0; i < messageCount; i++) { - var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, ref pairSnapshot); + var messageHeight = MeasureMessageHeight(channel, channel.Messages[i], showTimestamps, cursorStartX, contentMaxX, itemSpacing, mentionHighlightData, ref pairSnapshot); if (messageHeight <= 0f) { messageHeight = lineHeightWithSpacing; @@ -511,6 +698,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.BeginGroup(); ImGui.PushStyleColor(ImGuiCol.Text, color); + var mentionContextOpen = false; if (showRoleIcons) { if (!string.IsNullOrEmpty(timestampText)) @@ -557,12 +745,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX); + mentionContextOpen = DrawChatMessageWithEmotes($"{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData); } else { var messageStartX = ImGui.GetCursorPosX(); - DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX); + mentionContextOpen = DrawChatMessageWithEmotes($"{timestampText}{message.DisplayName}: ", payload.Message, messageStartX, mentionHighlightData); } ImGui.PopStyleColor(); ImGui.EndGroup(); @@ -570,7 +758,8 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetNextWindowSizeConstraints( new Vector2(190f * ImGuiHelpers.GlobalScale, 0f), new Vector2(float.MaxValue, float.MaxValue)); - if (ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}")) + var messagePopupFlags = ImGuiPopupFlags.MouseButtonRight | ImGuiPopupFlags.NoOpenOverExistingPopup; + if (!mentionContextOpen && ImGui.BeginPopupContextItem($"chat_msg_ctx##{channel.Key}_{i}", messagePopupFlags)) { var contextLocalTimestamp = payload.SentAtUtc.ToLocalTime(); var contextTimestampText = contextLocalTimestamp.ToString("yyyy-MM-dd HH:mm:ss 'UTC'z", CultureInfo.InvariantCulture); @@ -619,12 +808,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private void DrawChatMessageWithEmotes(string prefix, string message, float lineStartX) + private bool DrawChatMessageWithEmotes(string prefix, string message, float lineStartX, MentionHighlightData? mentionHighlightData) { - var segments = BuildChatSegments(prefix, message); + var segments = BuildChatSegments(prefix, message, mentionHighlightData); var firstOnLine = true; - var emoteSize = new Vector2(ImGui.GetTextLineHeight()); + var emoteSizeValue = ImGui.GetTextLineHeight() * GetEmoteScale(); + var emoteSize = new Vector2(emoteSizeValue); var remainingWidth = ImGui.GetContentRegionAvail().X; + var mentionIndex = 0; + var mentionContextOpen = false; foreach (var segment in segments) { @@ -674,13 +866,102 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } else { - ImGui.TextUnformatted(segment.Text); + if (segment.IsMention) + { + Vector4 mentionColor = segment.IsSelfMention + ? UIColors.Get("LightlessYellow") + : UIColors.Get("LightlessPurple"); + ImGui.PushStyleColor(ImGuiCol.Text, mentionColor); + ImGui.TextUnformatted(segment.Text); + ImGui.PopStyleColor(); + mentionContextOpen |= DrawMentionContextMenu(segment.Text, mentionHighlightData, mentionIndex++); + } + else + { + ImGui.TextUnformatted(segment.Text); + } } remainingWidth -= segmentWidth; firstOnLine = false; } + return mentionContextOpen; + } + + private bool DrawMentionContextMenu(string mentionText, MentionHighlightData? mentionHighlightData, int mentionIndex) + { + string token = mentionText; + if (!string.IsNullOrEmpty(token) && token[0] == '@') + { + token = token[1..]; + } + + MentionUserInfo? mentionInfo = null; + if (mentionHighlightData.HasValue + && !string.IsNullOrWhiteSpace(token) + && mentionHighlightData.Value.Users.TryGetValue(token, out var userInfo)) + { + mentionInfo = userInfo; + } + + string statusLabel = "Unknown"; + bool canViewProfile = false; + Action? viewProfileAction = null; + + if (mentionInfo.HasValue) + { + var info = mentionInfo.Value; + if (info.IsSelf) + { + statusLabel = "You"; + } + else if (info.Pair is not null) + { + statusLabel = info.Pair.IsOnline ? "Online" : "Offline"; + } + + if (info.Pair is not null) + { + canViewProfile = true; + viewProfileAction = () => Mediator.Publish(new ProfileOpenStandaloneMessage(info.Pair)); + } + else if (info.UserData is not null) + { + canViewProfile = true; + var userData = info.UserData; + viewProfileAction = () => RunContextAction(() => OpenStandardProfileAsync(userData)); + } + } + + var style = ImGui.GetStyle(); + var iconWidth = _uiSharedService.GetIconSize(FontAwesomeIcon.User).X; + var actionWidth = ImGui.CalcTextSize("View Profile").X + iconWidth + style.ItemSpacing.X; + var baseWidth = MathF.Max( + MathF.Max(ImGui.CalcTextSize(mentionText).X, ImGui.CalcTextSize(statusLabel).X), + actionWidth); + var targetWidth = (baseWidth + style.WindowPadding.X * 2f + style.FramePadding.X * 2f) * 1.5f; + ImGui.SetNextWindowSizeConstraints(new Vector2(targetWidth, 0f), new Vector2(float.MaxValue, float.MaxValue)); + + if (!ImGui.BeginPopupContextItem($"mention_ctx##{mentionIndex}")) + { + return false; + } + + ImGui.TextUnformatted(mentionText); + ImGui.Separator(); + ImGui.TextDisabled(statusLabel); + ImGui.Separator(); + + var profileAction = new ChatMessageContextAction( + FontAwesomeIcon.User, + "View Profile", + canViewProfile, + viewProfileAction ?? NoopContextAction); + DrawContextMenuAction(profileAction, 0); + + ImGui.EndPopup(); + return true; } private void DrawEmotePickerPopup(ref string draft, string channelKey) @@ -817,15 +1098,15 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - private List BuildChatSegments(string prefix, string message) + private List BuildChatSegments(string prefix, string message, MentionHighlightData? mentionHighlightData) { var segments = new List(Math.Max(16, message.Length / 4)); - AppendChatSegments(segments, prefix, allowEmotes: false); - AppendChatSegments(segments, message, allowEmotes: true); + AppendChatSegments(segments, prefix, allowEmotes: false, mentionHighlightData: null); + AppendChatSegments(segments, message, allowEmotes: true, mentionHighlightData); return segments; } - private void AppendChatSegments(List segments, string text, bool allowEmotes) + private void AppendChatSegments(List segments, string text, bool allowEmotes, MentionHighlightData? mentionHighlightData) { if (string.IsNullOrEmpty(text)) { @@ -867,6 +1148,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } var token = text[tokenStart..index]; + if (mentionHighlightData.HasValue + && TrySplitMentionToken(token, mentionHighlightData.Value, out var leadingMention, out var mentionText, out var trailingMention, out var isSelfMention)) + { + if (!string.IsNullOrEmpty(leadingMention)) + { + segments.Add(ChatSegment.FromText(leadingMention)); + } + + segments.Add(ChatSegment.Mention(mentionText, isSelfMention)); + + if (!string.IsNullOrEmpty(trailingMention)) + { + segments.Add(ChatSegment.FromText(trailingMention)); + } + + continue; + } if (allowEmotes && TrySplitToken(token, out var leading, out var core, out var trailing)) { if (_chatEmoteService.TryGetEmote(core, out var texture) && texture is not null) @@ -925,6 +1223,451 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '!' || value == '(' || value == ')'; } + private static bool TrySplitMentionToken(string token, MentionHighlightData mentionHighlightData, out string leading, out string mentionText, out string trailing, out bool isSelfMention) + { + leading = string.Empty; + mentionText = string.Empty; + trailing = string.Empty; + isSelfMention = false; + + if (string.IsNullOrEmpty(token) || mentionHighlightData.Tokens.Count == 0) + { + return false; + } + + for (int index = 0; index < token.Length; index++) + { + if (token[index] != '@') + { + continue; + } + + if (index > 0 && IsMentionChar(token[index - 1])) + { + continue; + } + + int start = index + 1; + int end = start; + while (end < token.Length && IsMentionChar(token[end])) + { + end++; + } + + if (end == start) + { + continue; + } + + string mentionToken = token[start..end]; + if (!mentionHighlightData.Tokens.TryGetValue(mentionToken, out bool matchedSelf)) + { + continue; + } + + leading = token[..index]; + mentionText = "@" + mentionToken; + trailing = token[end..]; + isSelfMention = matchedSelf; + return true; + } + + return false; + } + + private static bool IsMentionChar(char value) + { + return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\''; + } + + private static bool IsMentionToken(ReadOnlySpan token, bool allowEmpty) + { + if (token.Length == 0) + { + return allowEmpty; + } + + for (int i = 0; i < token.Length; i++) + { + if (!IsMentionChar(token[i])) + { + return false; + } + } + + return true; + } + + private static bool TryGetMentionQuery(string text, out MentionQuery mentionQuery) + { + mentionQuery = default; + if (string.IsNullOrEmpty(text)) + { + return false; + } + + int cursor = text.Length; + int index = cursor - 1; + while (index >= 0) + { + char current = text[index]; + if (current == '@') + { + if (index > 0 && IsMentionChar(text[index - 1])) + { + return false; + } + + ReadOnlySpan tokenSpan = text.AsSpan(index + 1, cursor - (index + 1)); + if (!IsMentionToken(tokenSpan, allowEmpty: true)) + { + return false; + } + + mentionQuery = new MentionQuery(index, cursor, tokenSpan.ToString()); + return true; + } + + if (char.IsWhiteSpace(current)) + { + return false; + } + + if (!IsMentionChar(current)) + { + return false; + } + + index--; + } + + return false; + } + + private static string? GetPreferredMentionToken(string uid, string? alias) + { + if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false)) + { + return alias; + } + + if (IsMentionToken(uid.AsSpan(), allowEmpty: false)) + { + return uid; + } + + return null; + } + + private static void AddMentionToken(Dictionary tokens, string token, bool isSelf) + { + if (tokens.TryGetValue(token, out bool existing)) + { + if (isSelf && !existing) + { + tokens[token] = true; + } + + return; + } + + tokens[token] = isSelf; + } + + private static void AddMentionUserToken( + Dictionary users, + HashSet ambiguousTokens, + string token, + MentionUserInfo info) + { + if (ambiguousTokens.Contains(token)) + { + return; + } + + if (users.TryGetValue(token, out var existing)) + { + if (!string.Equals(existing.Uid, info.Uid, StringComparison.Ordinal)) + { + users.Remove(token); + ambiguousTokens.Add(token); + } + + return; + } + + users[token] = info; + } + + private static void AddMentionData( + Dictionary tokens, + Dictionary users, + HashSet ambiguousTokens, + string uid, + string? alias, + bool isSelf, + Pair? pair, + UserData? userData) + { + if (string.IsNullOrWhiteSpace(uid)) + { + return; + } + + var info = new MentionUserInfo(uid, userData, pair, isSelf); + if (IsMentionToken(uid.AsSpan(), allowEmpty: false)) + { + AddMentionToken(tokens, uid, isSelf); + AddMentionUserToken(users, ambiguousTokens, uid, info); + } + + if (!string.IsNullOrWhiteSpace(alias) && IsMentionToken(alias.AsSpan(), allowEmpty: false)) + { + AddMentionToken(tokens, alias, isSelf); + AddMentionUserToken(users, ambiguousTokens, alias, info); + } + } + + private static IReadOnlyList GetPairsForGroup(PairUiSnapshot snapshot, string groupId, GroupFullInfoDto? groupInfo) + { + if (groupInfo is not null && snapshot.GroupPairs.TryGetValue(groupInfo, out IReadOnlyList groupPairs)) + { + return groupPairs; + } + + foreach (KeyValuePair> entry in snapshot.GroupPairs) + { + if (string.Equals(entry.Key.Group.GID, groupId, StringComparison.Ordinal)) + { + return entry.Value; + } + } + + return Array.Empty(); + } + + private void AddMentionCandidate(List candidates, HashSet seenTokens, string uid, string? alias, string? note, bool isSelf, bool includeSelf) + { + if (!includeSelf && isSelf) + { + return; + } + + string? token = GetPreferredMentionToken(uid, alias); + if (string.IsNullOrWhiteSpace(token)) + { + return; + } + + if (!seenTokens.Add(token)) + { + return; + } + + string displayName = !string.IsNullOrWhiteSpace(alias) ? alias : uid; + candidates.Add(new MentionCandidate(token, displayName, note, uid, isSelf)); + } + + private List BuildMentionCandidates(ChatChannelSnapshot channel, PairUiSnapshot snapshot, bool includeSelf) + { + List candidates = new(); + if (channel.Type != ChatChannelType.Group) + { + return candidates; + } + + string? groupId = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(groupId)) + { + return candidates; + } + + HashSet seenTokens = new(StringComparer.OrdinalIgnoreCase); + string selfUid = _apiController.UID; + + GroupFullInfoDto? groupInfo = null; + if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found)) + { + groupInfo = found; + } + + if (groupInfo is not null) + { + bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal); + string? ownerNote = _serverConfigurationManager.GetNoteForUid(groupInfo.Owner.UID); + AddMentionCandidate(candidates, seenTokens, groupInfo.Owner.UID, groupInfo.Owner.Alias, ownerNote, ownerIsSelf, includeSelf); + + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + string? note = pair.GetNote(); + AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf); + } + } + else + { + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, null); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + string? note = pair.GetNote(); + AddMentionCandidate(candidates, seenTokens, pair.UserData.UID, pair.UserData.Alias, note, isSelf, includeSelf); + } + } + + if (includeSelf) + { + string? note = _serverConfigurationManager.GetNoteForUid(selfUid); + AddMentionCandidate(candidates, seenTokens, selfUid, _apiController.DisplayName, note, isSelf: true, includeSelf: true); + } + + return candidates; + } + + private MentionHighlightData? BuildMentionHighlightData(ChatChannelSnapshot channel, PairUiSnapshot snapshot) + { + if (channel.Type != ChatChannelType.Group) + { + return null; + } + + string? groupId = channel.Descriptor.CustomKey; + if (string.IsNullOrWhiteSpace(groupId)) + { + return null; + } + + Dictionary tokens = new(StringComparer.OrdinalIgnoreCase); + Dictionary users = new(StringComparer.OrdinalIgnoreCase); + HashSet ambiguousTokens = new(StringComparer.OrdinalIgnoreCase); + string selfUid = _apiController.UID; + if (!string.IsNullOrWhiteSpace(selfUid)) + { + var selfData = new UserData(selfUid, _apiController.DisplayName); + snapshot.PairsByUid.TryGetValue(selfUid, out var selfPair); + AddMentionData(tokens, users, ambiguousTokens, selfUid, _apiController.DisplayName, true, selfPair, selfData); + } + + GroupFullInfoDto? groupInfo = null; + if (snapshot.GroupsByGid.TryGetValue(groupId, out GroupFullInfoDto found)) + { + groupInfo = found; + } + + if (groupInfo is not null) + { + bool ownerIsSelf = string.Equals(groupInfo.Owner.UID, selfUid, StringComparison.Ordinal); + var ownerUid = groupInfo.Owner.UID; + snapshot.PairsByUid.TryGetValue(ownerUid, out var ownerPair); + AddMentionData(tokens, users, ambiguousTokens, ownerUid, groupInfo.Owner.Alias, ownerIsSelf, ownerPair, groupInfo.Owner); + + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, groupInfo); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData); + } + } + else + { + IReadOnlyList groupPairs = GetPairsForGroup(snapshot, groupId, null); + foreach (Pair pair in groupPairs) + { + bool isSelf = string.Equals(pair.UserData.UID, selfUid, StringComparison.Ordinal); + AddMentionData(tokens, users, ambiguousTokens, pair.UserData.UID, pair.UserData.Alias, isSelf, pair, pair.UserData); + } + } + + if (tokens.Count == 0) + { + return null; + } + + return new MentionHighlightData(tokens, users); + } + + private static List FilterMentionCandidates(IEnumerable candidates, string query) + { + string trimmed = query.Trim(); + IEnumerable filtered = candidates; + + if (trimmed.Length > 0) + { + filtered = filtered.Where(candidate => + candidate.Token.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || candidate.DisplayName.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || candidate.Uid.Contains(trimmed, StringComparison.OrdinalIgnoreCase) + || (!string.IsNullOrWhiteSpace(candidate.Note) && candidate.Note.Contains(trimmed, StringComparison.OrdinalIgnoreCase))); + } + + List result = filtered + .OrderBy(candidate => string.IsNullOrWhiteSpace(candidate.Note) ? candidate.DisplayName : candidate.Note, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.DisplayName, StringComparer.OrdinalIgnoreCase) + .Take(MaxMentionSuggestions) + .ToList(); + return result; + } + + private static string BuildMentionLabel(MentionCandidate candidate) + { + string label = candidate.DisplayName; + if (!string.IsNullOrWhiteSpace(candidate.Note) && !string.Equals(candidate.Note, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) + { + label = $"{candidate.Note} ({label})"; + } + + if (!string.Equals(candidate.Token, candidate.DisplayName, StringComparison.OrdinalIgnoreCase)) + { + label = $"{label} [{candidate.Token}]"; + } + + return label; + } + + private static string ApplyMentionToDraft(string draft, MentionQuery mentionQuery, string token, int maxLength, out int cursorPos) + { + string before = mentionQuery.StartIndex > 0 ? draft[..mentionQuery.StartIndex] : string.Empty; + string after = mentionQuery.EndIndex < draft.Length ? draft[mentionQuery.EndIndex..] : string.Empty; + string mentionText = "@" + token; + + if (string.IsNullOrEmpty(after) || !char.IsWhiteSpace(after[0])) + { + mentionText += " "; + } + + string updated = before + mentionText + after; + if (updated.Length > maxLength) + { + updated = updated[..maxLength]; + } + + cursorPos = Math.Min(before.Length + mentionText.Length, updated.Length); + return updated; + } + + private unsafe int ChatInputCallback(ref ImGuiInputTextCallbackData data) + { + if (_pendingDraftCursorPos < 0) + { + return 0; + } + + if (!string.Equals(_pendingDraftCursorChannelKey, _activeInputChannelKey, StringComparison.Ordinal)) + { + return 0; + } + + int clampedCursor = Math.Clamp(_pendingDraftCursorPos, 0, data.BufTextLen); + data.CursorPos = clampedCursor; + data.SelectionStart = clampedCursor; + data.SelectionEnd = clampedCursor; + + _pendingDraftCursorPos = -1; + _pendingDraftCursorChannelKey = null; + return 0; + } + private float MeasureMessageHeight( ChatChannelSnapshot channel, ChatMessageEntry message, @@ -932,6 +1675,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase float cursorStartX, float contentMaxX, float itemSpacing, + MentionHighlightData? mentionHighlightData, ref PairUiSnapshot? pairSnapshot) { if (message.IsSystem) @@ -988,29 +1732,32 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase prefix = $"{timestampText}{message.DisplayName}: "; } - var lines = MeasureChatMessageLines(prefix, payload.Message, lineStartX, contentMaxX); - return Math.Max(1, lines) * ImGui.GetTextLineHeightWithSpacing(); + return MeasureChatMessageHeight(prefix, payload.Message, lineStartX, contentMaxX, mentionHighlightData); } - private int MeasureChatMessageLines(string prefix, string message, float lineStartX, float contentMaxX) + private float MeasureChatMessageHeight(string prefix, string message, float lineStartX, float contentMaxX, MentionHighlightData? mentionHighlightData) { - var segments = BuildChatSegments(prefix, message); + var segments = BuildChatSegments(prefix, message, mentionHighlightData); if (segments.Count == 0) { - return 1; + return ImGui.GetTextLineHeightWithSpacing(); } - var emoteWidth = ImGui.GetTextLineHeight(); + var baseLineHeight = ImGui.GetTextLineHeight(); + var emoteSize = baseLineHeight * GetEmoteScale(); + var spacingY = ImGui.GetStyle().ItemSpacing.Y; var availableWidth = Math.Max(1f, contentMaxX - lineStartX); var remainingWidth = availableWidth; var firstOnLine = true; - var lines = 1; + var lineHeight = baseLineHeight; + var totalHeight = 0f; foreach (var segment in segments) { if (segment.IsLineBreak) { - lines++; + totalHeight += lineHeight + spacingY; + lineHeight = baseLineHeight; firstOnLine = true; remainingWidth = availableWidth; continue; @@ -1021,12 +1768,13 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase continue; } - var segmentWidth = segment.IsEmote ? emoteWidth : ImGui.CalcTextSize(segment.Text).X; + var segmentWidth = segment.IsEmote ? emoteSize : ImGui.CalcTextSize(segment.Text).X; if (!firstOnLine) { if (segmentWidth > remainingWidth) { - lines++; + totalHeight += lineHeight + spacingY; + lineHeight = baseLineHeight; firstOnLine = true; remainingWidth = availableWidth; if (segment.IsWhitespace) @@ -1036,11 +1784,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + if (segment.IsEmote) + { + lineHeight = MathF.Max(lineHeight, emoteSize); + } + remainingWidth -= segmentWidth; firstOnLine = false; } - return lines; + totalHeight += lineHeight + spacingY; + return totalHeight; } private float MeasureRolePrefixWidth(string timestampText, bool isOwner, bool isModerator, bool isPinned, float itemSpacing) @@ -1089,6 +1843,9 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase return width; } + private float GetEmoteScale() + => Math.Clamp(_chatConfigService.Current.EmoteScale, MinEmoteScale, MaxEmoteScale); + private float MeasureIconWidth(FontAwesomeIcon icon) { using var font = _uiSharedService.IconFont.Push(); @@ -1179,11 +1936,17 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndTooltip(); } - private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak) + private readonly record struct MentionQuery(int StartIndex, int EndIndex, string Token); + private readonly record struct MentionCandidate(string Token, string DisplayName, string? Note, string Uid, bool IsSelf); + private readonly record struct MentionUserInfo(string Uid, UserData? UserData, Pair? Pair, bool IsSelf); + private readonly record struct MentionHighlightData(IReadOnlyDictionary Tokens, IReadOnlyDictionary Users); + + private readonly record struct ChatSegment(string Text, IDalamudTextureWrap? Texture, string? EmoteName, bool IsEmote, bool IsWhitespace, bool IsLineBreak, bool IsMention, bool IsSelfMention) { - public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false); - public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false); - public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true); + public static ChatSegment FromText(string text, bool isWhitespace = false) => new(text, null, null, false, isWhitespace, false, false, false); + public static ChatSegment Emote(IDalamudTextureWrap texture, string name) => new(string.Empty, texture, name, true, false, false, false, false); + public static ChatSegment LineBreak() => new(string.Empty, null, null, false, false, true, false, false); + public static ChatSegment Mention(string text, bool isSelfMention) => new(text, null, null, false, false, false, true, isSelfMention); } private void DrawInput(ChatChannelSnapshot channel) @@ -1207,18 +1970,152 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase _refocusChatInput = false; _refocusChatInputKey = null; } - ImGui.InputText(inputId, ref draft, MaxMessageLength); - if (ImGui.IsItemActive()) + _activeInputChannelKey = channel.Key; + ImGui.InputText(inputId, ref draft, MaxMessageLength, ImGuiInputTextFlags.CallbackAlways, ChatInputCallback); + _activeInputChannelKey = null; + Vector2 inputMin = ImGui.GetItemRectMin(); + Vector2 inputMax = ImGui.GetItemRectMax(); + bool inputActive = ImGui.IsItemActive(); + if (inputActive) { 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)); + drawList.AddRect(inputMin, inputMax, highlightU32, style.FrameRounding, ImDrawFlags.None, Math.Max(1f, ImGuiHelpers.GlobalScale)); } var enterPressed = ImGui.IsItemFocused() && (ImGui.IsKeyPressed(ImGuiKey.Enter) || ImGui.IsKeyPressed(ImGuiKey.KeypadEnter)); + bool mentionHandled = false; + bool showMentionPopup = false; + bool popupAlreadyOpen = ImGui.IsPopupOpen(MentionPopupId, ImGuiPopupFlags.AnyPopupLevel); + bool mentionContextActive = (inputActive || popupAlreadyOpen) && channel.Type == ChatChannelType.Group; + if (mentionContextActive) + { + if (TryGetMentionQuery(draft, out MentionQuery mentionQuery)) + { + PairUiSnapshot mentionSnapshot = _pairUiService.GetSnapshot(); + List mentionCandidates = BuildMentionCandidates(channel, mentionSnapshot, includeSelf: false); + List filteredCandidates = FilterMentionCandidates(mentionCandidates, mentionQuery.Token); + + if (filteredCandidates.Count > 0) + { + string mentionSelectionKey = $"{channel.Key}:{mentionQuery.Token}"; + if (!string.Equals(_mentionSelectionKey, mentionSelectionKey, StringComparison.Ordinal)) + { + _mentionSelectionKey = mentionSelectionKey; + _mentionSelectionIndex = 0; + } + else + { + _mentionSelectionIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1); + } + + if (ImGui.IsKeyPressed(ImGuiKey.DownArrow)) + { + _mentionSelectionIndex = Math.Min(_mentionSelectionIndex + 1, filteredCandidates.Count - 1); + } + + if (ImGui.IsKeyPressed(ImGuiKey.UpArrow)) + { + _mentionSelectionIndex = Math.Max(_mentionSelectionIndex - 1, 0); + } + + if (enterPressed || ImGui.IsKeyPressed(ImGuiKey.Tab)) + { + int selectedIndex = Math.Clamp(_mentionSelectionIndex, 0, filteredCandidates.Count - 1); + MentionCandidate selected = filteredCandidates[selectedIndex]; + draft = ApplyMentionToDraft(draft, mentionQuery, selected.Token, MaxMessageLength, out int cursorPos); + _pendingDraftCursorPos = cursorPos; + _pendingDraftCursorChannelKey = channel.Key; + _refocusChatInput = true; + _refocusChatInputKey = channel.Key; + enterPressed = false; + mentionHandled = true; + } + + bool popupRequested = inputActive && !mentionHandled; + showMentionPopup = popupRequested || popupAlreadyOpen; + if (showMentionPopup) + { + float popupWidth = Math.Max(260f * ImGuiHelpers.GlobalScale, inputMax.X - inputMin.X); + ImGui.SetNextWindowPos(new Vector2(inputMin.X, inputMax.Y + style.ItemSpacing.Y), ImGuiCond.Always); + ImGui.SetNextWindowSizeConstraints(new Vector2(popupWidth, 0f), new Vector2(popupWidth, float.MaxValue)); + } + + if (popupRequested && !popupAlreadyOpen) + { + ImGui.OpenPopup(MentionPopupId); + } + + const ImGuiWindowFlags mentionPopupFlags = ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoNavFocus | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings; + if (showMentionPopup && ImGui.BeginPopup(MentionPopupId, mentionPopupFlags)) + { + float lineHeight = ImGui.GetTextLineHeightWithSpacing(); + int visibleEntries = Math.Min(3, filteredCandidates.Count); + float desiredHeight = lineHeight * visibleEntries; + using (ImRaii.Child("##mention_list", new Vector2(-1f, desiredHeight), true)) + { + for (int i = 0; i < filteredCandidates.Count; i++) + { + MentionCandidate candidate = filteredCandidates[i]; + string label = BuildMentionLabel(candidate); + bool isSelected = i == _mentionSelectionIndex; + if (ImGui.Selectable(label, isSelected)) + { + draft = ApplyMentionToDraft(draft, mentionQuery, candidate.Token, MaxMessageLength, out int cursorPos); + _pendingDraftCursorPos = cursorPos; + _pendingDraftCursorChannelKey = channel.Key; + _refocusChatInput = true; + _refocusChatInputKey = channel.Key; + enterPressed = false; + mentionHandled = true; + ImGui.CloseCurrentPopup(); + break; + } + + if (ImGui.IsItemHovered()) + { + _mentionSelectionIndex = i; + } + } + } + + ImGui.EndPopup(); + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + } + else + { + _mentionSelectionKey = null; + _mentionSelectionIndex = -1; + if (popupAlreadyOpen && ImGui.BeginPopup(MentionPopupId)) + { + ImGui.CloseCurrentPopup(); + ImGui.EndPopup(); + } + } + _draftMessages[channel.Key] = draft; ImGui.SameLine(); @@ -1586,6 +2483,25 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase public override void PostDraw() { + if (_forceExpandOnOpen && IsOpen) + { + Collapsed = null; + _forceExpandOnOpen = false; + } + if (IsOpen) + { + var metricsUpdated = TryUpdateWindowMetricsFromBase(); + if (metricsUpdated) + { + var isCollapsed = IsLikelyCollapsed(_lastWindowSize); + UpdateCollapsedState(isCollapsed); + } + + if (_isWindowCollapsed && _collapsedMessageCount > 0 && _hasWindowMetrics) + { + DrawCollapsedMessageBadge(ImGui.GetForegroundDrawList(), _lastWindowPos, _lastWindowSize); + } + } if (_pushedStyle) { ImGui.PopStyleVar(1); @@ -1975,13 +2891,59 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase }); } + private void HandleIncomingMessageForCollapsedBadge(ChatMessageEntry message) + { + if (!IsCountableIncomingMessage(message)) + { + return; + } + + var config = _chatConfigService.Current; + if (!IsOpen) + { + if (config.AutoOpenChatOnNewMessage && !ShouldHide()) + { + IsOpen = true; + Collapsed = false; + CollapsedCondition = ImGuiCond.Appearing; + _forceExpandOnOpen = true; + } + + return; + } + + if (_isWindowCollapsed) + { + if (_collapsedMessageCount < CollapsedMessageCountDisplayCap + 1) + { + _collapsedMessageCount++; + } + } + } + + private static bool IsCountableIncomingMessage(ChatMessageEntry message) + { + if (message.FromSelf || message.IsSystem) + { + return false; + } + + return message.Payload?.Message is { Length: > 0 }; + } + private void OnChatChannelMessageAdded(ChatChannelMessageAdded message) { - if (_selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) + var channelHidden = IsChannelHidden(message.ChannelKey); + if (!channelHidden && _selectedChannelKey is not null && string.Equals(_selectedChannelKey, message.ChannelKey, StringComparison.Ordinal)) { _scrollToBottom = true; } + if (!channelHidden) + { + HandleIncomingMessageForCollapsedBadge(message.Message); + } + if (!message.Message.FromSelf || message.Message.Payload?.Message is not { Length: > 0 } payloadText) { return; @@ -2092,9 +3054,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (_selectedChannelKey is not null && channels.Any(channel => string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal))) return; - _selectedChannelKey = channels.Count > 0 ? channels[0].Key : null; - if (_selectedChannelKey is not null) + string? nextKey = channels.Count > 0 ? channels[0].Key : null; + if (!string.Equals(_selectedChannelKey, nextKey, StringComparison.Ordinal)) { + _selectedChannelKey = nextKey; _zoneChatService.SetActiveChannel(_selectedChannelKey); _scrollToBottom = true; } @@ -2118,6 +3081,43 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } + private IReadOnlyList GetVisibleChannels(IReadOnlyList channels) + { + Dictionary hiddenChannels = _chatConfigService.Current.HiddenChannels; + if (hiddenChannels.Count == 0) + { + return channels; + } + + List visibleChannels = new List(channels.Count); + foreach (var channel in channels) + { + if (!hiddenChannels.TryGetValue(channel.Key, out var isHidden) || !isHidden) + { + visibleChannels.Add(channel); + } + } + + return visibleChannels; + } + + private bool IsChannelHidden(string channelKey) + => _chatConfigService.Current.HiddenChannels.TryGetValue(channelKey, out var isHidden) && isHidden; + + private void SetChannelHidden(string channelKey, bool hidden) + { + if (hidden) + { + _chatConfigService.Current.HiddenChannels[channelKey] = true; + } + else + { + _chatConfigService.Current.HiddenChannels.Remove(channelKey); + } + + _chatConfigService.Save(); + } + private void DrawConnectionControls() { var hubState = _apiController.ServerState; @@ -2289,14 +3289,50 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private void DrawChatSettingsPopup() { const ImGuiWindowFlags popupFlags = ImGuiWindowFlags.AlwaysAutoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings; + var workSize = ImGui.GetMainViewport().WorkSize; + var minWidth = MathF.Min(420f * ImGuiHelpers.GlobalScale, workSize.X * 0.9f); + var minHeight = MathF.Min(360f * ImGuiHelpers.GlobalScale, workSize.Y * 0.85f); + var minSize = new Vector2(minWidth, minHeight); + var maxSize = new Vector2( + MathF.Max(minSize.X, workSize.X * 0.95f), + MathF.Max(minSize.Y, workSize.Y * 0.95f)); + ImGui.SetNextWindowSizeConstraints(minSize, maxSize); + ImGui.SetNextWindowSize(minSize, ImGuiCond.Appearing); if (!ImGui.BeginPopup(SettingsPopupId, popupFlags)) return; ImGui.TextUnformatted("Chat Settings"); ImGui.Separator(); + UiSharedService.Tab("ChatSettingsTabs", ChatSettingsTabOptions, ref _selectedChatSettingsTab); + ImGuiHelpers.ScaledDummy(5); + var chatConfig = _chatConfigService.Current; + switch (_selectedChatSettingsTab) + { + case ChatSettingsTab.General: + DrawChatSettingsGeneral(chatConfig); + break; + case ChatSettingsTab.Messages: + DrawChatSettingsMessages(chatConfig); + break; + case ChatSettingsTab.Notifications: + DrawChatSettingsNotifications(chatConfig); + break; + case ChatSettingsTab.Visibility: + DrawChatSettingsVisibility(chatConfig); + break; + case ChatSettingsTab.Window: + DrawChatSettingsWindow(chatConfig); + break; + } + + ImGui.EndPopup(); + } + + private void DrawChatSettingsGeneral(ChatConfig chatConfig) + { var autoEnable = chatConfig.AutoEnableChatOnLogin; if (ImGui.Checkbox("Auto-enable chat on login", ref autoEnable)) { @@ -2338,6 +3374,28 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { ImGui.SetTooltip("Toggles if the rules popup appears everytime the chat is opened for the first time."); } + } + + private void DrawChatSettingsMessages(ChatConfig chatConfig) + { + 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("Adjust scale of chat message text.\nRight-click to reset to default."); + } var showTimestamps = chatConfig.ShowMessageTimestamps; if (ImGui.Checkbox("Show message timestamps", ref showTimestamps)) @@ -2372,8 +3430,116 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetTooltip("When disabled, emotes render as static images."); } + var emoteScale = Math.Clamp(chatConfig.EmoteScale, MinEmoteScale, MaxEmoteScale); + var emoteScaleChanged = ImGui.SliderFloat("Emote size", ref emoteScale, MinEmoteScale, MaxEmoteScale, "%.2fx"); + var resetEmoteScale = ImGui.IsItemClicked(ImGuiMouseButton.Right); + if (resetEmoteScale) + { + emoteScale = 1.0f; + emoteScaleChanged = true; + } + + if (emoteScaleChanged) + { + chatConfig.EmoteScale = emoteScale; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Scales emotes relative to text height.\nRight-click to reset to default."); + } + + ImGui.Separator(); + ImGui.TextUnformatted("History"); + ImGui.Separator(); + + bool persistHistory = chatConfig.PersistSyncshellHistory; + if (ImGui.Checkbox("Persist syncshell chat history", ref persistHistory)) + { + chatConfig.PersistSyncshellHistory = persistHistory; + _chatConfigService.Save(); + if (!persistHistory) + { + _zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: false); + } + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Stores the latest 200 syncshell messages on disk and restores them when chat loads.\nStored messages are considered stale and cannot be muted or reported."); + } + + bool hasPersistedHistory = chatConfig.SyncshellChannelHistory.Count > 0; + using (ImRaii.Disabled(!hasPersistedHistory || !UiSharedService.CtrlPressed())) + { + if (ImGui.Button("Clear saved syncshell history")) + { + _zoneChatService.ClearPersistedSyncshellHistory(clearLoadedMessages: true); + } + } + UiSharedService.AttachToolTip("Clears saved syncshell chat history and loaded cached messages." + + UiSharedService.TooltipSeparator + "Hold CTRL to enable this button"); + } + + private void DrawChatSettingsNotifications(ChatConfig chatConfig) + { + var notifyMentions = chatConfig.EnableMentionNotifications; + if (ImGui.Checkbox("Notify on mentions", ref notifyMentions)) + { + chatConfig.EnableMentionNotifications = notifyMentions; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Show a notification when someone mentions you in syncshell chat."); + } + + var autoOpenOnMessage = chatConfig.AutoOpenChatOnNewMessage; + if (ImGui.Checkbox("Auto-open chat on new messages when closed", ref autoOpenOnMessage)) + { + chatConfig.AutoOpenChatOnNewMessage = autoOpenOnMessage; + _chatConfigService.Save(); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Reopens the chat window when a new message arrives while it is closed."); + } + } + + private void DrawChatSettingsVisibility(ChatConfig chatConfig) + { + ImGui.TextUnformatted("Channel Visibility"); + ImGui.Separator(); + + IReadOnlyList channels = _zoneChatService.GetChannelsSnapshot(); + if (channels.Count == 0) + { + ImGui.TextDisabled("No chat channels available."); + } + else + { + ImGui.TextDisabled("Uncheck a channel to hide its tab."); + ImGui.TextDisabled("Hidden channels still receive messages."); + + float maxListHeight = 200f * ImGuiHelpers.GlobalScale; + float listHeight = Math.Min(maxListHeight, channels.Count * ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y); + using var child = ImRaii.Child("chat_channel_visibility_list", new Vector2(0f, listHeight), true); + if (child) + { + foreach (var channel in channels) + { + bool isVisible = !IsChannelHidden(channel.Key); + string prefix = channel.Type == ChatChannelType.Zone ? "Zone" : "Syncshell"; + if (ImGui.Checkbox($"{prefix}: {channel.DisplayName}##{channel.Key}", ref isVisible)) + { + SetChannelHidden(channel.Key, !isVisible); + } + } + } + } + ImGui.Separator(); ImGui.TextUnformatted("Chat Visibility"); + ImGui.Separator(); var autoHideCombat = chatConfig.HideInCombat; if (ImGui.Checkbox("Hide in combat", ref autoHideCombat)) @@ -2434,28 +3600,10 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase { 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); - if (resetFontScale) - { - fontScale = 1.0f; - fontScaleChanged = true; - } - - if (fontScaleChanged) - { - chatConfig.ChatFontScale = fontScale; - _chatConfigService.Save(); - } - if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip("Adjust scale of chat message text.\nRight-click to reset to default."); - } - + private void DrawChatSettingsWindow(ChatConfig chatConfig) + { 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); @@ -2484,7 +3632,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } if (ImGui.IsItemHovered()) { - ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores focus."); + ImGui.SetTooltip("When enabled, the chat window fades after it loses focus.\nHovering the window restores opacity."); } ImGui.BeginDisabled(!fadeUnfocused); @@ -2506,8 +3654,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase 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) @@ -2542,27 +3688,49 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase private unsafe 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; + ImGuiStylePtr style = ImGui.GetStyle(); + Vector2 baseFramePadding = style.FramePadding; + float available = ImGui.GetContentRegionAvail().X; + float buttonHeight = ImGui.GetFrameHeight(); + float arrowWidth = buttonHeight; + bool hasChannels = channels.Count > 0; + float scrollWidth = hasChannels ? Math.Max(0f, available - (arrowWidth * 2f + style.ItemSpacing.X * 2f)) : 0f; if (hasChannels) { - var minimumWidth = 120f * ImGuiHelpers.GlobalScale; + float minimumWidth = 120f * ImGuiHelpers.GlobalScale; scrollWidth = Math.Max(scrollWidth, minimumWidth); } - var scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f; + float scrollStep = scrollWidth > 0f ? scrollWidth * 0.9f : 120f; + float badgeSpacing = 4f * ImGuiHelpers.GlobalScale; + Vector2 badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; + bool showScrollbar = false; + if (hasChannels) + { + float totalWidth = 0f; + bool firstWidth = true; + foreach (ChatChannelSnapshot channel in channels) + { + if (!firstWidth) + { + totalWidth += style.ItemSpacing.X; + } + + totalWidth += GetChannelTabWidth(channel, baseFramePadding, badgeSpacing, badgePadding); + firstWidth = false; + } + + showScrollbar = totalWidth > scrollWidth; + } + + float childHeight = buttonHeight + style.FramePadding.Y * 2f + (showScrollbar ? style.ScrollbarSize : 0f); if (!hasChannels) { _pendingChannelScroll = null; _channelScroll = 0f; _channelScrollMax = 0f; } - var prevScroll = hasChannels ? _channelScroll : 0f; - var prevMax = hasChannels ? _channelScrollMax : 0f; + float prevScroll = hasChannels ? _channelScroll : 0f; + float prevMax = hasChannels ? _channelScrollMax : 0f; float currentScroll = prevScroll; float maxScroll = prevMax; @@ -2587,7 +3755,6 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SameLine(0f, style.ItemSpacing.X); - var childHeight = buttonHeight + style.FramePadding.Y * 2f + style.ScrollbarSize; var alignPushed = false; if (hasChannels) { @@ -2595,31 +3762,29 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase alignPushed = true; } - const int MaxBadgeDisplay = 99; - using (var child = ImRaii.Child("channel_scroll", new Vector2(scrollWidth, childHeight), false, ImGuiWindowFlags.HorizontalScrollbar)) { if (child) { - var dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left); - var hoveredTargetThisFrame = false; - var first = true; + bool dragActive = _dragChannelKey is not null && ImGui.IsMouseDragging(ImGuiMouseButton.Left); + bool hoveredTargetThisFrame = false; + bool 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; + bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); + bool showBadge = !isSelected && channel.UnreadCount > 0; + bool isZoneChannel = channel.Type == ChatChannelType.Zone; (string Text, Vector2 TextSize, float Width, float Height)? badgeMetrics = null; - var channelLabel = GetChannelTabLabel(channel); + string channelLabel = GetChannelTabLabel(channel); - var normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); - var hovered = isSelected + Vector4 normal = isSelected ? UIColors.Get("LightlessPurpleDefault") : UIColors.Get("ButtonDefault"); + Vector4 hovered = isSelected ? UIColors.Get("LightlessPurple").WithAlpha(0.9f) : UIColors.Get("ButtonDefault").WithAlpha(0.85f); - var active = isSelected + Vector4 active = isSelected ? UIColors.Get("LightlessPurpleDefault").WithAlpha(0.8f) : UIColors.Get("ButtonDefault").WithAlpha(0.75f); @@ -2629,20 +3794,18 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (showBadge) { - var badgeSpacing = 4f * ImGuiHelpers.GlobalScale; - var badgePadding = new Vector2(4f, 1.5f) * ImGuiHelpers.GlobalScale; - var badgeText = channel.UnreadCount > MaxBadgeDisplay + string badgeText = channel.UnreadCount > MaxBadgeDisplay ? $"{MaxBadgeDisplay}+" : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); - 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); + Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText); + float badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + float badgeHeight = badgeTextSize.Y + badgePadding.Y * 2f; + Vector2 customPadding = new Vector2(baseFramePadding.X + badgeWidth + badgeSpacing, baseFramePadding.Y); ImGui.PushStyleVar(ImGuiStyleVar.FramePadding, customPadding); badgeMetrics = (badgeText, badgeTextSize, badgeWidth, badgeHeight); } - var clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}"); + bool clicked = ImGui.Button($"{channelLabel}##chat_channel_{channel.Key}"); if (showBadge) { @@ -2678,12 +3841,12 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndDragDropSource(); } - var isDragTarget = false; + bool isDragTarget = false; if (ImGui.BeginDragDropTarget()) { - var acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect; - var payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags); + ImGuiDragDropFlags acceptFlags = ImGuiDragDropFlags.AcceptBeforeDelivery | ImGuiDragDropFlags.AcceptNoDrawDefaultRect; + ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload(ChannelDragPayloadId, acceptFlags); if (!payload.IsNull && _dragChannelKey is { } draggedKey && !string.Equals(draggedKey, channel.Key, StringComparison.Ordinal)) { @@ -2698,7 +3861,7 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.EndDragDropTarget(); } - var isHoveredDuringDrag = dragActive + bool isHoveredDuringDrag = dragActive && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenBlockedByActiveItem | ImGuiHoveredFlags.AllowWhenOverlapped); if (!isDragTarget && isHoveredDuringDrag @@ -2712,14 +3875,14 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase } } - var drawList = ImGui.GetWindowDrawList(); - var itemMin = ImGui.GetItemRectMin(); - var itemMax = ImGui.GetItemRectMax(); + ImDrawListPtr drawList = ImGui.GetWindowDrawList(); + Vector2 itemMin = ImGui.GetItemRectMin(); + Vector2 itemMax = ImGui.GetItemRectMax(); if (isHoveredDuringDrag) { - var highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); - var highlightU32 = ImGui.ColorConvertFloat4ToU32(highlight); + Vector4 highlight = UIColors.Get("LightlessPurple").WithAlpha(0.35f); + uint 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)); } @@ -2731,23 +3894,23 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase if (isZoneChannel) { - var borderColor = UIColors.Get("LightlessOrange"); - var borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); - var borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale); + Vector4 borderColor = UIColors.Get("LightlessOrange"); + uint borderColorU32 = ImGui.ColorConvertFloat4ToU32(borderColor); + float borderThickness = Math.Max(1f, ImGuiHelpers.GlobalScale); drawList.AddRect(itemMin, itemMax, borderColorU32, style.FrameRounding, ImDrawFlags.None, borderThickness); } if (showBadge && badgeMetrics is { } metrics) { - var buttonSizeY = itemMax.Y - itemMin.Y; - var badgeMin = new Vector2( + float buttonSizeY = itemMax.Y - itemMin.Y; + Vector2 badgeMin = new Vector2( itemMin.X + baseFramePadding.X, 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); + Vector2 badgeMax = badgeMin + new Vector2(metrics.Width, metrics.Height); + Vector4 badgeColor = UIColors.Get("DimRed"); + uint badgeColorU32 = ImGui.ColorConvertFloat4ToU32(badgeColor); drawList.AddRectFilled(badgeMin, badgeMax, badgeColorU32, metrics.Height * 0.5f); - var textPos = new Vector2( + Vector2 textPos = new Vector2( 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); @@ -2810,6 +3973,26 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase ImGui.SetCursorPosY(ImGui.GetCursorPosY() - style.ItemSpacing.Y * 0.3f); } + private float GetChannelTabWidth(ChatChannelSnapshot channel, Vector2 baseFramePadding, float badgeSpacing, Vector2 badgePadding) + { + string channelLabel = GetChannelTabLabel(channel); + float textWidth = ImGui.CalcTextSize(channelLabel).X; + bool isSelected = string.Equals(channel.Key, _selectedChannelKey, StringComparison.Ordinal); + bool showBadge = !isSelected && channel.UnreadCount > 0; + if (!showBadge) + { + return textWidth + baseFramePadding.X * 2f; + } + + string badgeText = channel.UnreadCount > MaxBadgeDisplay + ? $"{MaxBadgeDisplay}+" + : channel.UnreadCount.ToString(CultureInfo.InvariantCulture); + Vector2 badgeTextSize = ImGui.CalcTextSize(badgeText); + float badgeWidth = badgeTextSize.X + badgePadding.X * 2f; + float customPaddingX = baseFramePadding.X + badgeWidth + badgeSpacing; + return textWidth + customPaddingX * 2f; + } + private string GetChannelTabLabel(ChatChannelSnapshot channel) { if (channel.Type != ChatChannelType.Group) @@ -2845,27 +4028,33 @@ public sealed class ZoneChatUi : WindowMediatorSubscriberBase 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); + return true; } private void DrawChannelTabContextMenu(ChatChannelSnapshot channel) { + if (ImGui.MenuItem("Hide Channel")) + { + SetChannelHidden(channel.Key, true); + if (string.Equals(_selectedChannelKey, channel.Key, StringComparison.Ordinal)) + { + _selectedChannelKey = null; + _zoneChatService.SetActiveChannel(null); + } + ImGui.CloseCurrentPopup(); + return; + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip("Unhide channels from Chat Settings -> Visibility."); + } + var preferNote = _chatConfigService.Current.PreferNotesForChannels.TryGetValue(channel.Key, out var value) && value; var note = GetChannelNote(channel); var hasNote = !string.IsNullOrWhiteSpace(note); if (preferNote || hasNote) { + ImGui.Separator(); var label = preferNote ? "Prefer Name Instead" : "Prefer Note Instead"; if (ImGui.MenuItem(label)) { diff --git a/LightlessSync/Utils/TaskRegistry.cs b/LightlessSync/Utils/TaskRegistry.cs new file mode 100644 index 0000000..d888bbd --- /dev/null +++ b/LightlessSync/Utils/TaskRegistry.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; + + +namespace LightlessSync.Utils; + +public sealed class TaskRegistry where HandleType : notnull +{ + private readonly ConcurrentDictionary _activeTasks = new(); + + public Task GetOrStart(HandleType handle, Func taskFactory) + { + ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); + return entry.EnsureStarted(); + } + + public Task GetOrStart(HandleType handle, Func> taskFactory) + { + ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory))); + return (Task)entry.EnsureStarted(); + } + + public bool TryGetExisting(HandleType handle, out Task task) + { + if (_activeTasks.TryGetValue(handle, out ActiveTask? entry)) + { + task = entry.EnsureStarted(); + return true; + } + + task = Task.CompletedTask; + return false; + } + + private async Task ExecuteAndRemove(HandleType handle, Func taskFactory) + { + try + { + await taskFactory().ConfigureAwait(false); + } + finally + { + _activeTasks.TryRemove(handle, out _); + } + } + + private async Task ExecuteAndRemove(HandleType handle, Func> taskFactory) + { + try + { + return await taskFactory().ConfigureAwait(false); + } + finally + { + _activeTasks.TryRemove(handle, out _); + } + } + + private sealed class ActiveTask + { + private readonly object _gate = new(); + private readonly Func _starter; + private Task? _cached; + + public ActiveTask(Func starter) + { + _starter = starter; + } + + public Task EnsureStarted() + { + lock (_gate) + { + if (_cached == null || _cached.IsCompleted) + { + _cached = _starter(); + } + return _cached; + } + } + } +} diff --git a/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs b/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs new file mode 100644 index 0000000..42850a2 --- /dev/null +++ b/LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs @@ -0,0 +1,48 @@ +using System.Collections.Concurrent; + +namespace LightlessSync.WebAPI.Files; + +public readonly record struct DownloadClaim(bool IsOwner, Task Completion); + +public sealed class FileDownloadDeduplicator +{ + private readonly ConcurrentDictionary> _inFlight = + new(StringComparer.OrdinalIgnoreCase); + + public DownloadClaim Claim(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return new DownloadClaim(false, Task.FromResult(true)); + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var existing = _inFlight.GetOrAdd(hash, tcs); + var isOwner = ReferenceEquals(existing, tcs); + return new DownloadClaim(isOwner, existing.Task); + } + + public void Complete(string hash, bool success) + { + if (string.IsNullOrWhiteSpace(hash)) + { + return; + } + + if (_inFlight.TryRemove(hash, out var tcs)) + { + tcs.TrySetResult(success); + } + } + + public void CompleteAll(bool success) + { + foreach (var entry in _inFlight.ToArray()) + { + if (_inFlight.TryRemove(entry.Key, out var tcs)) + { + tcs.TrySetResult(success); + } + } + } +} diff --git a/LightlessSync/WebAPI/Files/FileDownloadManager.cs b/LightlessSync/WebAPI/Files/FileDownloadManager.cs index 97f8af7..2b51bf5 100644 --- a/LightlessSync/WebAPI/Files/FileDownloadManager.cs +++ b/LightlessSync/WebAPI/Files/FileDownloadManager.cs @@ -1,3 +1,4 @@ +using K4os.Compression.LZ4; using K4os.Compression.LZ4.Legacy; using LightlessSync.API.Data; using LightlessSync.API.Dto.Files; @@ -8,9 +9,13 @@ using LightlessSync.PlayerData.Handlers; using LightlessSync.Services.Mediator; using LightlessSync.Services.ModelDecimation; using LightlessSync.Services.TextureCompression; +using LightlessSync.Utils; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Buffers.Binary; using System.Collections.Concurrent; +using System.IO.MemoryMappedFiles; using System.Net; using System.Net.Http.Json; @@ -18,8 +23,6 @@ namespace LightlessSync.WebAPI.Files; public partial class FileDownloadManager : DisposableMediatorSubscriberBase { - private readonly ConcurrentDictionary _downloadStatus; - private readonly FileCompactor _fileCompactor; private readonly FileCacheManager _fileDbManager; private readonly FileTransferOrchestrator _orchestrator; @@ -27,12 +30,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase private readonly TextureDownscaleService _textureDownscaleService; private readonly ModelDecimationService _modelDecimationService; private readonly TextureMetadataHelper _textureMetadataHelper; + private readonly FileDownloadDeduplicator _downloadDeduplicator; + private readonly ConcurrentDictionary _activeSessions = new(); + private readonly ConcurrentDictionary> _downloadQueues = new(); + private readonly TaskRegistry _downloadQueueWaiters = new(); private readonly ConcurrentDictionary _activeDownloadStreams; private readonly SemaphoreSlim _decompressGate = new(Math.Max(1, Environment.ProcessorCount / 2), Math.Max(1, Environment.ProcessorCount / 2)); - - private readonly ConcurrentQueue _deferredCompressionQueue = new(); private volatile bool _disableDirectDownloads; private int _consecutiveDirectDownloadFailures; @@ -47,9 +52,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase LightlessConfigService configService, TextureDownscaleService textureDownscaleService, ModelDecimationService modelDecimationService, - TextureMetadataHelper textureMetadataHelper) : base(logger, mediator) + TextureMetadataHelper textureMetadataHelper, + FileDownloadDeduplicator downloadDeduplicator) : base(logger, mediator) { - _downloadStatus = new ConcurrentDictionary(StringComparer.Ordinal); _orchestrator = orchestrator; _fileDbManager = fileCacheManager; _fileCompactor = fileCompactor; @@ -57,6 +62,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _textureDownscaleService = textureDownscaleService; _modelDecimationService = modelDecimationService; _textureMetadataHelper = textureMetadataHelper; + _downloadDeduplicator = downloadDeduplicator; _activeDownloadStreams = new(); _lastConfigDirectDownloadsState = _configService.Current.EnableDirectDownloads; @@ -70,12 +76,46 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase foreach (var stream in _activeDownloadStreams.Keys) stream.BandwidthLimit = newLimit; }); + + Mediator.Subscribe(this, _ => + { + Logger.LogDebug("Disconnected from server, clearing in-flight downloads"); + ClearDownload(); + _downloadDeduplicator.CompleteAll(false); + }); } - public List CurrentDownloads { get; private set; } = []; + public List CurrentDownloads => _activeSessions.Values.SelectMany(s => s.Downloads).ToList(); public List ForbiddenTransfers => _orchestrator.ForbiddenTransfers; - public Guid? CurrentOwnerToken { get; private set; } - public bool IsDownloading => CurrentDownloads.Count != 0; + public bool IsDownloading => !_activeSessions.IsEmpty || _downloadQueues.Any(kvp => !kvp.Value.IsEmpty); + + public bool IsDownloadingFor(GameObjectHandler? handler) + { + if (handler is null) + return false; + + return _activeSessions.ContainsKey(handler) + || (_downloadQueues.TryGetValue(handler, out var queue) && !queue.IsEmpty); + } + + public int GetPendingDownloadCount(GameObjectHandler? handler) + { + if (handler is null) + return 0; + + var count = 0; + + if (_activeSessions.TryGetValue(handler, out var session)) + count += session.Downloads.Count; + + if (_downloadQueues.TryGetValue(handler, out var queue)) + { + foreach (var request in queue) + count += request.Session.Downloads.Count; + } + + return count; + } private bool ShouldUseDirectDownloads() => _configService.Current.EnableDirectDownloads && !_disableDirectDownloads; @@ -88,26 +128,111 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase public void ClearDownload() { - CurrentDownloads.Clear(); - _downloadStatus.Clear(); - CurrentOwnerToken = null; + foreach (var session in _activeSessions.Values.ToList()) + ClearDownload(session); + } + + private void ClearDownload(DownloadSession session) + { + foreach (var hash in session.OwnedDownloads.Keys.ToList()) + { + CompleteOwnedDownload(session, hash, false); + } + + session.Status.Clear(); + session.OwnedDownloads.Clear(); + session.Downloads.Clear(); + + if (session.Handler is not null) + _activeSessions.TryRemove(session.Handler, out _); } public async Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false) { + var downloads = await InitiateDownloadList(gameObject, fileReplacementDto, ct).ConfigureAwait(false); + await DownloadFiles(gameObject, fileReplacementDto, downloads, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + } + + public Task DownloadFiles(GameObjectHandler? gameObject, List fileReplacementDto, List downloads, CancellationToken ct, bool skipDownscale = false, bool skipDecimation = false) + { + var session = new DownloadSession(gameObject, downloads); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var request = new DownloadRequest(session, fileReplacementDto, ct, skipDownscale, skipDecimation, completion); + return EnqueueDownloadAsync(request); + } + + private Task EnqueueDownloadAsync(DownloadRequest request) + { + var handler = request.Session.Handler; + if (handler is null) + { + _ = ExecuteDownloadRequestAsync(request); + return request.Completion.Task; + } + + var queue = _downloadQueues.GetOrAdd(handler, _ => new ConcurrentQueue()); + queue.Enqueue(request); + + _downloadQueueWaiters.GetOrStart(handler, () => ProcessDownloadQueueAsync(handler)); + + return request.Completion.Task; + } + + private async Task ProcessDownloadQueueAsync(GameObjectHandler handler) + { + if (!_downloadQueues.TryGetValue(handler, out var queue)) + return; + + while (true) + { + while (queue.TryDequeue(out var request)) + { + await ExecuteDownloadRequestAsync(request).ConfigureAwait(false); + } + + await Task.Yield(); + if (queue.IsEmpty) + return; + } + } + + private async Task ExecuteDownloadRequestAsync(DownloadRequest request) + { + if (request.CancellationToken.IsCancellationRequested) + { + request.Completion.TrySetCanceled(request.CancellationToken); + return; + } + + var session = request.Session; + if (session.Handler is not null) + { + _activeSessions[session.Handler] = session; + } + Mediator.Publish(new HaltScanMessage(nameof(DownloadFiles))); try { - await DownloadFilesInternal(gameObject, fileReplacementDto, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + await DownloadFilesInternal(session, request.Replacements, request.CancellationToken, request.SkipDownscale, request.SkipDecimation).ConfigureAwait(false); + request.Completion.TrySetResult(true); } - catch + catch (OperationCanceledException) when (request.CancellationToken.IsCancellationRequested) { - ClearDownload(); + ClearDownload(session); + request.Completion.TrySetCanceled(request.CancellationToken); + } + catch (Exception ex) + { + ClearDownload(session); + request.Completion.TrySetException(ex); } finally { - if (gameObject is not null) - Mediator.Publish(new DownloadFinishedMessage(gameObject)); + if (session.Handler is not null) + { + Mediator.Publish(new DownloadFinishedMessage(session.Handler)); + _activeSessions.TryRemove(session.Handler, out _); + } Mediator.Publish(new ResumeScanMessage(nameof(DownloadFiles))); } @@ -130,6 +255,30 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase base.Dispose(disposing); } + private sealed class DownloadSession + { + public DownloadSession(GameObjectHandler? handler, List downloads) + { + Handler = handler; + ObjectName = handler?.Name ?? "Unknown"; + Downloads = downloads; + } + + public GameObjectHandler? Handler { get; } + public string ObjectName { get; } + public List Downloads { get; } + public ConcurrentDictionary Status { get; } = new(StringComparer.Ordinal); + public ConcurrentDictionary OwnedDownloads { get; } = new(StringComparer.OrdinalIgnoreCase); + } + + private sealed record DownloadRequest( + DownloadSession Session, + List Replacements, + CancellationToken CancellationToken, + bool SkipDownscale, + bool SkipDecimation, + TaskCompletionSource Completion); + private sealed class DownloadSlotLease : IAsyncDisposable { private readonly FileTransferOrchestrator _orch; @@ -154,24 +303,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return new DownloadSlotLease(_orchestrator); } - private void SetStatus(string key, DownloadStatus status) + private void SetStatus(DownloadSession session, string key, DownloadStatus status) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.DownloadStatus = status; } - private void AddTransferredBytes(string key, long delta) + private void AddTransferredBytes(DownloadSession session, string key, long delta) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.AddTransferredBytes(delta); } - private void MarkTransferredFiles(string key, int files) + private void MarkTransferredFiles(DownloadSession session, string key, int files) { - if (_downloadStatus.TryGetValue(key, out var st)) + if (session.Status.TryGetValue(key, out var st)) st.SetTransferredFiles(files); } + private void CompleteOwnedDownload(DownloadSession session, string hash, bool success) + { + if (session.OwnedDownloads.TryRemove(hash, out _)) + { + _downloadDeduplicator.Complete(hash, success); + } + } + private static byte MungeByte(int byteOrEof) { if (byteOrEof == -1) throw new EndOfStreamException(); @@ -218,6 +375,101 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } } + private static async Task CopyExactlyAsync(Stream source, Stream destination, long bytesToCopy, CancellationToken ct) + { + if (bytesToCopy <= 0) + return; + + var buffer = ArrayPool.Shared.Rent(81920); + try + { + long remaining = bytesToCopy; + while (remaining > 0) + { + int read = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, remaining)), ct).ConfigureAwait(false); + if (read == 0) throw new EndOfStreamException(); + await destination.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + remaining -= read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private async Task DecompressWrappedLz4ToFileAsync(string compressedPath, string outputPath, CancellationToken ct) + { + await using var input = new FileStream(compressedPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true); + byte[] header = new byte[8]; + await ReadExactlyAsync(input, header, ct).ConfigureAwait(false); + + int outputLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(0, 4)); + int inputLength = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(4, 4)); + + if (outputLength < 0 || inputLength < 0) + throw new InvalidDataException("LZ4 header contained a negative length."); + + long remainingLength = input.Length - 8; + if (inputLength > remainingLength) + throw new InvalidDataException("LZ4 header length exceeds file size."); + + var dir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (outputLength == 0) + { + await using var emptyStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true); + await emptyStream.FlushAsync(ct).ConfigureAwait(false); + return 0; + } + + if (inputLength >= outputLength) + { + await using var outputStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true); + await CopyExactlyAsync(input, outputStream, inputLength, ct).ConfigureAwait(false); + await outputStream.FlushAsync(ct).ConfigureAwait(false); + return outputLength; + } + + await using var mappedOutputStream = new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.SequentialScan); + mappedOutputStream.SetLength(outputLength); + + using var inputMap = MemoryMappedFile.CreateFromFile(compressedPath, FileMode.Open, null, 0, MemoryMappedFileAccess.Read); + using var inputView = inputMap.CreateViewAccessor(8, inputLength, MemoryMappedFileAccess.Read); + using var outputMap = MemoryMappedFile.CreateFromFile(mappedOutputStream, null, outputLength, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true); + using var outputView = outputMap.CreateViewAccessor(0, outputLength, MemoryMappedFileAccess.Write); + + unsafe + { + byte* inputPtr = null; + byte* outputPtr = null; + try + { + inputView.SafeMemoryMappedViewHandle.AcquirePointer(ref inputPtr); + outputView.SafeMemoryMappedViewHandle.AcquirePointer(ref outputPtr); + + inputPtr += inputView.PointerOffset; + outputPtr += outputView.PointerOffset; + + int decoded = LZ4Codec.Decode(inputPtr, inputLength, outputPtr, outputLength); + if (decoded != outputLength) + throw new InvalidDataException($"LZ4 decode length mismatch (expected {outputLength}, got {decoded})."); + } + finally + { + if (inputPtr != null) + inputView.SafeMemoryMappedViewHandle.ReleasePointer(); + if (outputPtr != null) + outputView.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + + outputView.Flush(); + return outputLength; + } + private static Dictionary BuildReplacementLookup(List fileReplacement) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -426,6 +678,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } private async Task DownloadQueuedBlockFileAsync( + DownloadSession session, string statusKey, Guid requestId, List transfers, @@ -437,14 +690,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase requestId, transfers[0].DownloadUri, string.Join(", ", transfers.Select(c => c.Hash))); // Wait for ready WITHOUT holding a slot - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); await WaitForDownloadReady(transfers, requestId, ct).ConfigureAwait(false); // Hold slot ONLY for the GET - SetStatus(statusKey, DownloadStatus.WaitingForSlot); + SetStatus(session, statusKey, DownloadStatus.WaitingForSlot); await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) { - SetStatus(statusKey, DownloadStatus.Downloading); + SetStatus(session, statusKey, DownloadStatus.Downloading); var requestUrl = LightlessFiles.CacheGetFullPath(transfers[0].DownloadUri, requestId); await DownloadFileThrottled(requestUrl, tempPath, progress, MungeBuffer, ct, withToken: true).ConfigureAwait(false); @@ -452,6 +705,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase } private async Task DecompressBlockFileAsync( + DownloadSession session, string downloadStatusKey, string blockFilePath, Dictionary replacementLookup, @@ -461,8 +715,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase bool skipDownscale, bool skipDecimation) { - SetStatus(downloadStatusKey, DownloadStatus.Decompressing); - MarkTransferredFiles(downloadStatusKey, 1); + SetStatus(session, downloadStatusKey, DownloadStatus.Decompressing); + MarkTransferredFiles(session, downloadStatusKey, 1); try { @@ -483,6 +737,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (!replacementLookup.TryGetValue(fileHash, out var repl)) { Logger.LogWarning("{dlName}: No replacement mapping for {fileHash}", downloadLabel, fileHash); + CompleteOwnedDownload(session, fileHash, false); // still need to skip bytes: var skip = checked((int)fileLengthBytes); fileBlockStream.Position += skip; @@ -503,49 +758,29 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase && expectedRawSize > 0 && decompressed.LongLength != expectedRawSize) { - await _fileCompactor.WriteAllBytesAsync(filePath, Array.Empty(), ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); + Logger.LogWarning("{dlName}: Decompressed size mismatch for {fileHash} (expected {expected}, got {actual})", + downloadLabel, fileHash, expectedRawSize, decompressed.LongLength); + CompleteOwnedDownload(session, fileHash, false); continue; } - MungeBuffer(compressed); - - await _decompressGate.WaitAsync(ct).ConfigureAwait(false); - try - { - // offload CPU-intensive decompression to threadpool to free up worker - await Task.Run(async () => - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - - // decompress - var decompressed = LZ4Wrapper.Unwrap(compressed); - - Logger.LogTrace("{dlName}: Unwrap {fileHash} took {ms}ms (compressed {c} bytes, decompressed {d} bytes)", - downloadLabel, fileHash, sw.ElapsedMilliseconds, compressed.Length, decompressed?.Length ?? -1); - - // write to file without compacting during download - await File.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); - PersistFileToStorage(fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); - }, ct).ConfigureAwait(false); - } - finally - { - _decompressGate.Release(); - } + await _fileCompactor.WriteAllBytesAsync(filePath, decompressed, ct).ConfigureAwait(false); + PersistFileToStorage(session, fileHash, filePath, repl.GamePath, skipDownscale, skipDecimation); } catch (EndOfStreamException) { Logger.LogWarning("{dlName}: Failure to extract file {fileHash}, stream ended prematurely", downloadLabel, fileHash); + CompleteOwnedDownload(session, fileHash, false); } catch (Exception e) { Logger.LogWarning(e, "{dlName}: Error during decompression", downloadLabel); + CompleteOwnedDownload(session, fileHash, false); } } } - SetStatus(downloadStatusKey, DownloadStatus.Completed); + SetStatus(session, downloadStatusKey, DownloadStatus.Completed); } catch (EndOfStreamException) { @@ -563,15 +798,14 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase CancellationToken ct, Guid? ownerToken = null) { - CurrentOwnerToken = ownerToken; + _ = ownerToken; var objectName = gameObjectHandler?.Name ?? "Unknown"; Logger.LogDebug("Download start: {id}", objectName); if (fileReplacement == null || fileReplacement.Count == 0) { Logger.LogDebug("{dlName}: No file replacements provided", objectName); - CurrentDownloads = []; - return CurrentDownloads; + return []; } var hashes = fileReplacement @@ -583,13 +817,32 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase if (hashes.Count == 0) { Logger.LogDebug("{dlName}: No valid hashes to download", objectName); - CurrentDownloads = []; - return CurrentDownloads; + return []; + } + + var missingHashes = new List(hashes.Count); + foreach (var hash in hashes) + { + if (_fileDbManager.GetFileCacheByHash(hash) is null) + { + missingHashes.Add(hash); + } + } + + if (missingHashes.Count == 0) + { + Logger.LogDebug("{dlName}: All requested hashes already present in cache", objectName); + return []; + } + + if (missingHashes.Count < hashes.Count) + { + Logger.LogDebug("{dlName}: Skipping {count} hashes already present in cache", objectName, hashes.Count - missingHashes.Count); } List downloadFileInfoFromService = [ - .. await FilesGetSizes(hashes, ct).ConfigureAwait(false), + .. await FilesGetSizes(missingHashes, ct).ConfigureAwait(false), ]; Logger.LogDebug("Files with size 0 or less: {files}", @@ -601,13 +854,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase _orchestrator.ForbiddenTransfers.Add(new DownloadFileTransfer(dto)); } - CurrentDownloads = downloadFileInfoFromService + var downloads = downloadFileInfoFromService .Distinct() .Select(d => new DownloadFileTransfer(d)) .Where(d => d.CanBeTransferred) .ToList(); - return CurrentDownloads; + return downloads; } private sealed record BatchChunk(string HostKey, string StatusKey, List Items); @@ -618,9 +871,9 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase yield return items.GetRange(i, Math.Min(chunkSize, items.Count - i)); } - private async Task DownloadFilesInternal(GameObjectHandler? gameObjectHandler, List fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation) + private async Task DownloadFilesInternal(DownloadSession session, List fileReplacement, CancellationToken ct, bool skipDownscale, bool skipDecimation) { - var objectName = gameObjectHandler?.Name ?? "Unknown"; + var objectName = session.ObjectName; // config toggles var configAllowsDirect = _configService.Current.EnableDirectDownloads; @@ -638,7 +891,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var replacementLookup = BuildReplacementLookup(fileReplacement); var rawSizeLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var download in CurrentDownloads) + foreach (var download in session.Downloads) { if (string.IsNullOrWhiteSpace(download.Hash)) { @@ -654,7 +907,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var directDownloads = new List(); var batchDownloads = new List(); - foreach (var download in CurrentDownloads) + foreach (var download in session.Downloads) { if (!string.IsNullOrEmpty(download.DirectDownloadUrl) && allowDirectDownloads) directDownloads.Add(download); @@ -662,6 +915,60 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase batchDownloads.Add(download); } + session.OwnedDownloads.Clear(); + var waitingHashes = new HashSet(StringComparer.OrdinalIgnoreCase); + var waitTasks = new List>(); + var claims = new Dictionary(StringComparer.OrdinalIgnoreCase); + + DownloadClaim GetClaim(string hash) + { + if (!claims.TryGetValue(hash, out var claim)) + { + claim = _downloadDeduplicator.Claim(hash); + claims[hash] = claim; + } + + return claim; + } + + List FilterOwned(List downloads) + { + if (downloads.Count == 0) + { + return downloads; + } + + var owned = new List(downloads.Count); + foreach (var download in downloads) + { + if (string.IsNullOrWhiteSpace(download.Hash)) + { + continue; + } + + var claim = GetClaim(download.Hash); + if (claim.IsOwner) + { + session.OwnedDownloads.TryAdd(download.Hash, 0); + owned.Add(download); + } + else if (waitingHashes.Add(download.Hash)) + { + waitTasks.Add(claim.Completion); + } + } + + return owned; + } + + directDownloads = FilterOwned(directDownloads); + batchDownloads = FilterOwned(batchDownloads); + + if (waitTasks.Count > 0) + { + Logger.LogDebug("{dlName}: {count} files already downloading elsewhere; waiting for completion.", objectName, waitTasks.Count); + } + // Chunk per host so we can fill all slots var slots = Math.Max(1, _configService.Current.ParallelDownloads); @@ -679,12 +986,12 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase .ToArray(); // init statuses - _downloadStatus.Clear(); + session.Status.Clear(); // direct downloads and batch downloads tracked separately foreach (var d in directDownloads) { - _downloadStatus[d.DirectDownloadUrl!] = new FileDownloadStatus + session.Status[d.DirectDownloadUrl!] = new FileDownloadStatus { DownloadStatus = DownloadStatus.WaitingForSlot, TotalBytes = d.Total, @@ -696,7 +1003,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase foreach (var chunk in batchChunks) { - _downloadStatus[chunk.StatusKey] = new FileDownloadStatus + session.Status[chunk.StatusKey] = new FileDownloadStatus { DownloadStatus = DownloadStatus.WaitingForQueue, TotalBytes = chunk.Items.Sum(x => x.Total), @@ -712,8 +1019,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase directDownloads.Count, batchDownloads.Count, batchChunks.Length); } - if (gameObjectHandler is not null) - Mediator.Publish(new DownloadStartedMessage(gameObjectHandler, _downloadStatus)); + if (session.Handler is not null) + Mediator.Publish(new DownloadStartedMessage(session.Handler, session.Status)); // work based on cpu count and slots var coreCount = Environment.ProcessorCount; @@ -724,33 +1031,53 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var extraWorkers = (availableDecompressSlots > 0 && coreCount >= 6) ? 2 : 0; // allow some extra workers so downloads can continue while earlier items decompress. - var workerDop = Math.Clamp(baseWorkers + extraWorkers, 2, coreCount); + var workerDop = Math.Clamp(slots * 2, 2, 16); + var decompressionTasks = new ConcurrentBag(); + using var decompressionLimiter = new SemaphoreSlim(CalculateDecompressionLimit(slots)); // batch downloads Task batchTask = batchChunks.Length == 0 ? Task.CompletedTask : Parallel.ForEachAsync(batchChunks, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (chunk, token) => await ProcessBatchChunkAsync(chunk, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); + async (chunk, token) => await ProcessBatchChunkAsync(session, chunk, replacementLookup, rawSizeLookup, decompressionTasks, decompressionLimiter, token, skipDownscale, skipDecimation).ConfigureAwait(false)); // direct downloads Task directTask = directDownloads.Count == 0 ? Task.CompletedTask : Parallel.ForEachAsync(directDownloads, new ParallelOptions { MaxDegreeOfParallelism = workerDop, CancellationToken = ct }, - async (d, token) => await ProcessDirectAsync(d, replacementLookup, rawSizeLookup, token, skipDownscale, skipDecimation).ConfigureAwait(false)); + async (d, token) => await ProcessDirectAsync(session, d, replacementLookup, rawSizeLookup, decompressionTasks, decompressionLimiter, token, skipDownscale, skipDecimation).ConfigureAwait(false)); - await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + Task dedupWaitTask = waitTasks.Count == 0 + ? Task.FromResult(Array.Empty()) + : Task.WhenAll(waitTasks); - // process deferred compressions after all downloads complete - await ProcessDeferredCompressionsAsync(ct).ConfigureAwait(false); + try + { + await Task.WhenAll(batchTask, directTask).ConfigureAwait(false); + } + finally + { + await WaitForAllTasksAsync(decompressionTasks).ConfigureAwait(false); + } + + var dedupResults = await dedupWaitTask.ConfigureAwait(false); + + if (waitTasks.Count > 0 && dedupResults.Any(r => !r)) + { + Logger.LogWarning("{dlName}: One or more shared downloads failed; missing files may remain.", objectName); + } Logger.LogDebug("Download end: {id}", objectName); - ClearDownload(); + ClearDownload(session); } private async Task ProcessBatchChunkAsync( + DownloadSession session, BatchChunk chunk, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter, CancellationToken ct, bool skipDownscale, bool skipDecimation) @@ -758,7 +1085,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var statusKey = chunk.StatusKey; // enqueue (no slot) - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); var requestIdResponse = await _orchestrator.SendRequestAsync( HttpMethod.Post, @@ -771,22 +1098,49 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); var fi = new FileInfo(blockFile); + var decompressionQueued = false; + try { - var progress = CreateInlineProgress(bytes => AddTransferredBytes(statusKey, bytes)); + // download (with slot) + var progress = CreateInlineProgress(bytes => AddTransferredBytes(session, statusKey, bytes)); // Download slot held on get - await DownloadQueuedBlockFileAsync(statusKey, requestId, chunk.Items, blockFile, progress, ct).ConfigureAwait(false); + await DownloadQueuedBlockFileAsync(session, statusKey, requestId, chunk.Items, blockFile, progress, ct).ConfigureAwait(false); // decompress if file exists if (!File.Exists(blockFile)) { Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); - SetStatus(statusKey, DownloadStatus.Completed); + SetStatus(session, statusKey, DownloadStatus.Completed); return; } + SetStatus(session, statusKey, DownloadStatus.Decompressing); - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + await DecompressBlockFileAsync(session, statusKey, blockFile, replacementLookup, rawSizeLookup, fi.Name, token, skipDownscale, skipDecimation) + .ConfigureAwait(false); + } + finally + { + try { File.Delete(blockFile); } catch {} + foreach (var item in chunk.Items) + { + if (!string.IsNullOrWhiteSpace(item.Hash)) + { + CompleteOwnedDownload(session, item.Hash, false); + } + } + } + }, + ct); + decompressionQueued = true; } catch (OperationCanceledException) { @@ -795,18 +1149,31 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase catch (Exception ex) { Logger.LogError(ex, "{dlName}: Error during batch chunk processing", fi.Name); - ClearDownload(); + ClearDownload(session); } finally { - try { File.Delete(blockFile); } catch { /* ignore */ } + if (!decompressionQueued) + { + try { File.Delete(blockFile); } catch { /* ignore */ } + foreach (var item in chunk.Items) + { + if (!string.IsNullOrWhiteSpace(item.Hash)) + { + CompleteOwnedDownload(session, item.Hash, false); + } + } + } } } private async Task ProcessDirectAsync( + DownloadSession session, DownloadFileTransfer directDownload, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter, CancellationToken ct, bool skipDownscale, bool skipDecimation) @@ -814,25 +1181,35 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var progress = CreateInlineProgress(bytes => { if (!string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) - AddTransferredBytes(directDownload.DirectDownloadUrl!, bytes); + AddTransferredBytes(session, directDownload.DirectDownloadUrl!, bytes); }); if (!ShouldUseDirectDownloads() || string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + try + { + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogError(ex, "{hash}: Error during direct download fallback.", directDownload.Hash); + CompleteOwnedDownload(session, directDownload.Hash, false); + throw; + } return; } var tempFilename = _fileDbManager.GetCacheFilePath(directDownload.Hash, "bin"); + var decompressionQueued = false; try { // Download slot held on get - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.WaitingForSlot); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.WaitingForSlot); await using ((await AcquireSlotAsync(ct).ConfigureAwait(false)).ConfigureAwait(false)) { - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Downloading); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Downloading); Logger.LogDebug("Beginning direct download of {hash} from {url}", directDownload.Hash, directDownload.DirectDownloadUrl); await DownloadFileThrottled(new Uri(directDownload.DirectDownloadUrl!), tempFilename, progress, callback: null, ct, withToken: false) @@ -841,13 +1218,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Interlocked.Exchange(ref _consecutiveDirectDownloadFailures, 0); - // Decompress/write - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Decompressing); - if (!replacementLookup.TryGetValue(directDownload.Hash, out var repl)) { Logger.LogWarning("{hash}: No replacement data found for direct download.", directDownload.Hash); - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + CompleteOwnedDownload(session, directDownload.Hash, false); return; } @@ -856,22 +1231,68 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase Logger.LogDebug("Decompressing direct download {hash} from {compressedFile} to {finalFile}", directDownload.Hash, tempFilename, finalFilename); - // Read compressed bytes and decompress in memory - byte[] compressedBytes = await File.ReadAllBytesAsync(tempFilename, ct).ConfigureAwait(false); - var decompressedBytes = LZ4Wrapper.Unwrap(compressedBytes); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Decompressing); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + var decompressedLength = await DecompressWrappedLz4ToFileAsync(tempFilename, finalFilename, token).ConfigureAwait(false); - if (directDownload.TotalRaw > 0 && decompressedBytes.LongLength != directDownload.TotalRaw) - { - throw new InvalidDataException( - $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedBytes.LongLength})"); - } + if (directDownload.TotalRaw > 0 && decompressedLength != directDownload.TotalRaw) + { + throw new InvalidDataException( + $"{directDownload.Hash}: Decompressed size mismatch (expected {directDownload.TotalRaw}, got {decompressedLength})"); + } - await _fileCompactor.WriteAllBytesAsync(finalFilename, decompressedBytes, ct).ConfigureAwait(false); - PersistFileToStorage(directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation); + _fileCompactor.NotifyFileWritten(finalFilename); + PersistFileToStorage(session, directDownload.Hash, finalFilename, repl.GamePath, skipDownscale, skipDecimation); - MarkTransferredFiles(directDownload.DirectDownloadUrl!, 1); - SetStatus(directDownload.DirectDownloadUrl!, DownloadStatus.Completed); - Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + MarkTransferredFiles(session, directDownload.DirectDownloadUrl!, 1); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + Logger.LogDebug("Finished direct download of {hash}.", directDownload.Hash); + } + catch (Exception ex) + { + var expectedDirectDownloadFailure = ex is InvalidDataException; + var failureCount = expectedDirectDownloadFailure ? 0 : Interlocked.Increment(ref _consecutiveDirectDownloadFailures); + + if (expectedDirectDownloadFailure) + Logger.LogInformation(ex, "{hash}: Direct download unavailable, attempting queued fallback.", directDownload.Hash); + else + Logger.LogWarning(ex, "{hash}: Direct download failed, attempting queued fallback.", directDownload.Hash); + + try + { + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, token, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); + + if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) + { + _disableDirectDownloads = true; + Logger.LogWarning("Disabling direct downloads for this session after {count} consecutive failures.", failureCount); + } + } + catch (Exception fallbackEx) + { + Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); + CompleteOwnedDownload(session, directDownload.Hash, false); + SetStatus(session, directDownload.DirectDownloadUrl!, DownloadStatus.Completed); + ClearDownload(session); + } + } + finally + { + try { File.Delete(tempFilename); } + catch + { + // ignore + } + } + }, + ct); + decompressionQueued = true; } catch (OperationCanceledException ex) { @@ -880,7 +1301,8 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase else Logger.LogWarning(ex, "{hash}: Direct download cancelled unexpectedly.", directDownload.Hash); - ClearDownload(); + CompleteOwnedDownload(session, directDownload.Hash, false); + ClearDownload(session); } catch (Exception ex) { @@ -894,7 +1316,7 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase try { - await ProcessDirectAsQueuedFallbackAsync(directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation).ConfigureAwait(false); + await ProcessDirectAsQueuedFallbackAsync(session, directDownload, replacementLookup, rawSizeLookup, progress, ct, skipDownscale, skipDecimation, decompressionTasks, decompressionLimiter).ConfigureAwait(false); if (!expectedDirectDownloadFailure && failureCount >= 3 && !_disableDirectDownloads) { @@ -905,34 +1327,41 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase catch (Exception fallbackEx) { Logger.LogError(fallbackEx, "{hash}: Error during direct download fallback.", directDownload.Hash); - ClearDownload(); + CompleteOwnedDownload(session, directDownload.Hash, false); + ClearDownload(session); } } finally { - try { File.Delete(tempFilename); } - catch + if (!decompressionQueued) { - // ignore + try { File.Delete(tempFilename); } + catch + { + // ignore + } } } } private async Task ProcessDirectAsQueuedFallbackAsync( + DownloadSession session, DownloadFileTransfer directDownload, Dictionary replacementLookup, IReadOnlyDictionary rawSizeLookup, IProgress progress, CancellationToken ct, bool skipDownscale, - bool skipDecimation) + bool skipDecimation, + ConcurrentBag decompressionTasks, + SemaphoreSlim decompressionLimiter) { if (string.IsNullOrEmpty(directDownload.DirectDownloadUrl)) throw new InvalidOperationException("Direct download fallback requested without a direct download URL."); var statusKey = directDownload.DirectDownloadUrl!; - SetStatus(statusKey, DownloadStatus.WaitingForQueue); + SetStatus(session, statusKey, DownloadStatus.WaitingForQueue); var requestIdResponse = await _orchestrator.SendRequestAsync( HttpMethod.Post, @@ -942,23 +1371,46 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase var requestId = Guid.Parse((await requestIdResponse.Content.ReadAsStringAsync(ct).ConfigureAwait(false)).Trim('"')); var blockFile = _fileDbManager.GetCacheFilePath(requestId.ToString("N"), "blk"); + var fi = new FileInfo(blockFile); + var decompressionQueued = false; try { - await DownloadQueuedBlockFileAsync(statusKey, requestId, [directDownload], blockFile, progress, ct).ConfigureAwait(false); + await DownloadQueuedBlockFileAsync(session, statusKey, requestId, [directDownload], blockFile, progress, ct).ConfigureAwait(false); if (!File.Exists(blockFile)) - throw new FileNotFoundException("Block file missing after direct download fallback.", blockFile); + { + Logger.LogWarning("{dlName}: Block file missing before extraction, skipping", fi.Name); + SetStatus(session, statusKey, DownloadStatus.Completed); + return; + } - await DecompressBlockFileAsync(statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", ct, skipDownscale, skipDecimation) - .ConfigureAwait(false); + SetStatus(session, statusKey, DownloadStatus.Decompressing); + EnqueueLimitedTask( + decompressionTasks, + decompressionLimiter, + async token => + { + try + { + await DecompressBlockFileAsync(session, statusKey, blockFile, replacementLookup, rawSizeLookup, $"fallback-{directDownload.Hash}", token, skipDownscale, skipDecimation) + .ConfigureAwait(false); + } + finally + { + try { File.Delete(blockFile); } catch {} + CompleteOwnedDownload(session, directDownload.Hash, false); + } + }, + ct); + decompressionQueued = true; } finally { - try { File.Delete(blockFile); } - catch + if (!decompressionQueued) { - // ignore + try { File.Delete(blockFile); } catch {} + CompleteOwnedDownload(session, directDownload.Hash, false); } } } @@ -977,9 +1429,10 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase return await response.Content.ReadFromJsonAsync>(cancellationToken: ct).ConfigureAwait(false) ?? []; } - private void PersistFileToStorage(string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation) + private bool PersistFileToStorage(DownloadSession session, string fileHash, string filePath, string gamePath, bool skipDownscale, bool skipDecimation) { var fi = new FileInfo(filePath); + var persisted = false; Func RandomDayInThePast() { @@ -993,13 +1446,13 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase fi.LastAccessTime = DateTime.Today; fi.LastWriteTime = RandomDayInThePast().Invoke(); - // queue file for deferred compression instead of compressing immediately - if (_configService.Current.UseCompactor) - _deferredCompressionQueue.Enqueue(filePath); - try { var entry = _fileDbManager.CreateCacheEntryWithKnownHash(filePath, fileHash); + if (entry != null && string.Equals(entry.Hash, fileHash, StringComparison.OrdinalIgnoreCase)) + { + persisted = true; + } if (!skipDownscale && _textureDownscaleService.ShouldScheduleDownscale(filePath)) { @@ -1021,62 +1474,67 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase File.Delete(filePath); _fileDbManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath); + persisted = false; } } catch (Exception ex) { Logger.LogWarning(ex, "Error creating cache entry"); } + finally + { + CompleteOwnedDownload(session, fileHash, persisted); + } + + return persisted; + } + + private static int CalculateDecompressionLimit(int downloadSlots) + { + var cpuBound = Math.Max(1, Math.Min(Environment.ProcessorCount, 4)); + return Math.Clamp(downloadSlots, 1, cpuBound); + } + + private static Task EnqueueLimitedTask( + ConcurrentBag tasks, + SemaphoreSlim limiter, + Func work, + CancellationToken ct) + { + var task = Task.Run(async () => + { + await limiter.WaitAsync(ct).ConfigureAwait(false); + try + { + await work(ct).ConfigureAwait(false); + } + finally + { + limiter.Release(); + } + }, ct); + + tasks.Add(task); + return task; + } + + private static async Task WaitForAllTasksAsync(ConcurrentBag tasks) + { + while (true) + { + var snapshot = tasks.ToArray(); + if (snapshot.Length == 0) + return; + + await Task.WhenAll(snapshot).ConfigureAwait(false); + + if (tasks.Count == snapshot.Length) + return; + } } private static IProgress CreateInlineProgress(Action callback) => new InlineProgress(callback); - private async Task ProcessDeferredCompressionsAsync(CancellationToken ct) - { - if (_deferredCompressionQueue.IsEmpty) - return; - - var filesToCompress = new List(); - while (_deferredCompressionQueue.TryDequeue(out var filePath)) - { - if (File.Exists(filePath)) - filesToCompress.Add(filePath); - } - - if (filesToCompress.Count == 0) - return; - - Logger.LogDebug("Starting deferred compression of {count} files", filesToCompress.Count); - - var compressionWorkers = Math.Clamp(Environment.ProcessorCount / 4, 2, 4); - - await Parallel.ForEachAsync(filesToCompress, - new ParallelOptions - { - MaxDegreeOfParallelism = compressionWorkers, - CancellationToken = ct - }, - async (filePath, token) => - { - try - { - await Task.Yield(); - if (_configService.Current.UseCompactor && File.Exists(filePath)) - { - var bytes = await File.ReadAllBytesAsync(filePath, token).ConfigureAwait(false); - await _fileCompactor.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); - Logger.LogTrace("Compressed file: {filePath}", filePath); - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to compress file: {filePath}", filePath); - } - }).ConfigureAwait(false); - - Logger.LogDebug("Completed deferred compression of {count} files", filesToCompress.Count); - } - private sealed class InlineProgress : IProgress { private readonly Action _callback; diff --git a/LightlessSync/packages.lock.json b/LightlessSync/packages.lock.json index 45d7722..5de5367 100644 --- a/LightlessSync/packages.lock.json +++ b/LightlessSync/packages.lock.json @@ -617,6 +617,12 @@ "resolved": "10.0.1", "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" }, + "lightlesscompactor": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[10.0.1, )" + } + }, "lightlesssync.api": { "type": "Project", "dependencies": {