sigma update
This commit is contained in:
18
LightlessCompactor/FileCache/CompactorInterfaces.cs
Normal file
18
LightlessCompactor/FileCache/CompactorInterfaces.cs
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string, byte> _pendingCompactions;
|
||||
private readonly ILogger<FileCompactor> _logger;
|
||||
private readonly LightlessConfigService _lightlessConfigService;
|
||||
private readonly DalamudUtilService _dalamudUtilService;
|
||||
private readonly ICompactorContext _context;
|
||||
private readonly ICompactionExecutor _compactionExecutor;
|
||||
|
||||
private readonly Channel<string> _compactionQueue;
|
||||
private readonly CancellationTokenSource _compactionCts = new();
|
||||
@@ -59,12 +57,12 @@ public sealed partial class FileCompactor : IDisposable
|
||||
XPRESS16K = 3
|
||||
}
|
||||
|
||||
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService)
|
||||
public FileCompactor(ILogger<FileCompactor> 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<string>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notify the compactor that a file was written directly (streamed) so it can enqueue compaction.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the File size for an BTRFS or NTFS file system for the given FileInfo
|
||||
/// </summary>
|
||||
/// <param name="path">Amount of blocks used in the disk</param>
|
||||
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,9 +1101,12 @@ public sealed partial class FileCompactor : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath))
|
||||
if (_context.UseCompactor && File.Exists(filePath))
|
||||
{
|
||||
if (!_compactionExecutor.TryCompact(filePath))
|
||||
CompactFile(filePath, workerId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_globalGate.Release();
|
||||
15
LightlessCompactor/LightlessCompactor.csproj
Normal file
15
LightlessCompactor/LightlessCompactor.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
19
LightlessCompactorWorker/LightlessCompactorWorker.csproj
Normal file
19
LightlessCompactorWorker/LightlessCompactorWorker.csproj
Normal file
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
270
LightlessCompactorWorker/Program.cs
Normal file
270
LightlessCompactorWorker/Program.cs
Normal file
@@ -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<int> 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 <path> [--wine] [--cache-folder <path>] [--verbose]");
|
||||
Console.Error.WriteLine(" or: LightlessCompactorWorker --pipe <name> [--wine] [--parent <pid>] [--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<FileCompactor>();
|
||||
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<int> 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<CompactorRequest>(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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
241
LightlessSync/FileCache/ExternalCompactionExecutor.cs
Normal file
241
LightlessSync/FileCache/ExternalCompactionExecutor.cs
Normal file
@@ -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<ExternalCompactionExecutor> _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<ExternalCompactionExecutor> 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<CompactorResponse>(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<string> { "--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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
20
LightlessSync/FileCache/PluginCompactorContext.cs
Normal file
20
LightlessSync/FileCache/PluginCompactorContext.cs
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<GameObjectHandler> _playerRelatedPointers = [];
|
||||
private readonly object _playerRelatedLock = new();
|
||||
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
|
||||
@@ -42,8 +41,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
|
||||
_dalamudUtil = dalamudUtil;
|
||||
_actorObjectService = actorObjectService;
|
||||
_gameObjectHandlerFactory = gameObjectHandlerFactory;
|
||||
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
|
||||
|
||||
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
|
||||
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
|
||||
Mediator.Subscribe<ActorUntrackedMessage>(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))
|
||||
_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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token);
|
||||
public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token, bool requestRedraw = true)
|
||||
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw);
|
||||
|
||||
public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
|
||||
=> _textures.ConvertTextureFileDirectAsync(job, token);
|
||||
|
||||
@@ -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,23 +76,11 @@ 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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase
|
||||
|
||||
public override string Name => "Penumbra.Textures";
|
||||
|
||||
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
|
||||
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? 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 () =>
|
||||
{
|
||||
|
||||
@@ -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<string> ChannelOrder { get; set; } = new();
|
||||
public Dictionary<string, bool> HiddenChannels { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, string> SyncshellChannelHistory { get; set; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" />
|
||||
<ProjectReference Include="..\LightlessCompactor\LightlessCompactor.csproj" />
|
||||
<ProjectReference Include="..\LightlessCompactorWorker\LightlessCompactorWorker.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\Penumbra.Api\Penumbra.Api.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
|
||||
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
|
||||
@@ -102,4 +104,12 @@
|
||||
<PackageReference Update="DalamudPackager" Version="14.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<CompactorWorkerFiles Include="..\LightlessCompactorWorker\bin\$(Configuration)\net10.0\*.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyCompactorWorker" AfterTargets="Build">
|
||||
<Copy SourceFiles="@(CompactorWorkerFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ public class PlayerDataFactory
|
||||
if (transientPaths.Count == 0)
|
||||
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
|
||||
|
||||
var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
|
||||
var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
|
||||
@@ -692,7 +692,6 @@ public class PlayerDataFactory
|
||||
|
||||
|
||||
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
|
||||
GameObjectHandler handler,
|
||||
HashSet<string> forwardResolve,
|
||||
HashSet<string> reverseResolve)
|
||||
{
|
||||
@@ -707,59 +706,6 @@ public class PlayerDataFactory
|
||||
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
|
||||
|
||||
Dictionary<string, List<string>> 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<string>(), Array.Empty<string[]>());
|
||||
|
||||
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++)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using LightlessSync.API.Data;
|
||||
using LightlessSync.API.Data;
|
||||
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
namespace LightlessSync.PlayerData.Pairs;
|
||||
|
||||
/// <summary>
|
||||
/// orchestrates the lifecycle of a paired character
|
||||
/// </summary>
|
||||
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
||||
{
|
||||
/// <summary>
|
||||
/// orchestrates the lifecycle of a paired character
|
||||
/// </summary>
|
||||
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
|
||||
{
|
||||
new string Ident { get; }
|
||||
bool Initialized { get; }
|
||||
bool IsVisible { get; }
|
||||
@@ -36,8 +36,9 @@
|
||||
void Initialize();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string> 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,6 +2231,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
if (handlerForApply.Address != nint.Zero)
|
||||
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
|
||||
|
||||
if (hasPap)
|
||||
{
|
||||
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
|
||||
if (removedPap > 0)
|
||||
{
|
||||
@@ -2164,6 +2249,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
|
||||
.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);
|
||||
}
|
||||
|
||||
LastAppliedDataBytes = -1;
|
||||
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -129,12 +129,15 @@ public sealed class Plugin : IDalamudPlugin
|
||||
services.AddSingleton<TextureDownscaleService>();
|
||||
services.AddSingleton<ModelDecimationService>();
|
||||
services.AddSingleton<GameObjectHandlerFactory>();
|
||||
services.AddSingleton<FileDownloadDeduplicator>();
|
||||
services.AddSingleton<FileDownloadManagerFactory>();
|
||||
services.AddSingleton<PairProcessingLimiter>();
|
||||
services.AddSingleton<XivDataAnalyzer>();
|
||||
services.AddSingleton<CharacterAnalyzer>();
|
||||
services.AddSingleton<TokenProvider>();
|
||||
services.AddSingleton<PluginWarningNotificationService>();
|
||||
services.AddSingleton<ICompactorContext, PluginCompactorContext>();
|
||||
services.AddSingleton<ICompactionExecutor, ExternalCompactionExecutor>();
|
||||
services.AddSingleton<FileCompactor>();
|
||||
services.AddSingleton<TagHandler>();
|
||||
services.AddSingleton<PairRequestService>();
|
||||
@@ -331,8 +334,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
pluginInterface,
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<LightlessMediator>(),
|
||||
sp.GetRequiredService<RedrawManager>(),
|
||||
sp.GetRequiredService<ActorObjectService>()));
|
||||
sp.GetRequiredService<RedrawManager>()));
|
||||
|
||||
services.AddSingleton(sp => new IpcCallerGlamourer(
|
||||
sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(),
|
||||
@@ -516,6 +518,7 @@ public sealed class Plugin : IDalamudPlugin
|
||||
sp.GetRequiredService<ILogger<UiService>>(),
|
||||
pluginInterface.UiBuilder,
|
||||
sp.GetRequiredService<LightlessConfigService>(),
|
||||
sp.GetRequiredService<DalamudUtilService>(),
|
||||
sp.GetRequiredService<WindowSystem>(),
|
||||
sp.GetServices<WindowMediatorSubscriberBase>(),
|
||||
sp.GetRequiredService<UiFactory>(),
|
||||
|
||||
@@ -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 playerObject = (GameObject*)localPlayerAddress;
|
||||
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
|
||||
if (candidateAddress == nint.Zero)
|
||||
return 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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DalamudLoginMessage>(this, _ => HandleLogin());
|
||||
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
|
||||
Mediator.Subscribe<ZoneSwitchEndMessage>(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<PersistedChatMessage>? 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<string> tokens = BuildSelfMentionTokens();
|
||||
if (tokens.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryFindMentionToken(message, tokens, out matchedToken);
|
||||
}
|
||||
|
||||
private HashSet<string> BuildSelfMentionTokens()
|
||||
{
|
||||
HashSet<string> 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<string> 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<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
|
||||
if (persisted.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<string> invalidKeys = new();
|
||||
foreach (KeyValuePair<string, string> entry in persisted)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
|
||||
{
|
||||
invalidKeys.Add(entry.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> 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<ChatMessageEntry> 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<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
|
||||
{
|
||||
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
|
||||
List<PersistedChatMessage> 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<PersistedChatMessage> persistedMessages)
|
||||
{
|
||||
if (!_chatConfigService.Current.PersistSyncshellHistory)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, string> 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<PersistedChatMessage> 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<PersistedChatMessage> persistedMessages)
|
||||
{
|
||||
persistedMessages = new List<PersistedChatMessage>();
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] jsonBytes = Convert.FromBase64String(base64);
|
||||
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(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<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
|
||||
if (cache.Count > 0)
|
||||
{
|
||||
List<string> 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<string, string> 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);
|
||||
}
|
||||
|
||||
@@ -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<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
|
||||
@@ -485,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
|
||||
}
|
||||
}
|
||||
|
||||
return FindOwnedPet(ownerEntityId, ownerAddress);
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
public async Task<IntPtr> 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<DalamudObjectKind, bool> 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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MdlFile.VertexType> 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:
|
||||
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:
|
||||
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<Vector2[]>();
|
||||
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<byte> 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<byte> 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<byte> target)
|
||||
private static void WriteNormal(MdlFile.VertexType type, Vector3 value, Span<byte> 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<byte> 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<byte> 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,20 +1467,25 @@ internal static class MdlDecimator
|
||||
|
||||
private static void WriteBlendIndices(MdlFile.VertexType type, BoneWeight weights, Span<byte> target)
|
||||
{
|
||||
if (type != MdlFile.VertexType.UByte4)
|
||||
if (type == MdlFile.VertexType.UByte4)
|
||||
{
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
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<byte> target)
|
||||
{
|
||||
if (type != MdlFile.VertexType.UByte4 && type != MdlFile.VertexType.NByte4)
|
||||
{
|
||||
if (type == MdlFile.VertexType.Single4)
|
||||
{
|
||||
@@ -1213,7 +1493,14 @@ internal static class MdlDecimator
|
||||
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)
|
||||
{
|
||||
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<byte> 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<byte> 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<byte> target)
|
||||
@@ -1324,6 +1676,58 @@ internal static class MdlDecimator
|
||||
WriteUByte4(normalized, target);
|
||||
}
|
||||
|
||||
private static void WriteShort2(Vector2 value, Span<byte> target)
|
||||
{
|
||||
BinaryPrimitives.WriteInt16LittleEndian(target[..2], ToShort(value.x));
|
||||
BinaryPrimitives.WriteInt16LittleEndian(target.Slice(2, 2), ToShort(value.y));
|
||||
}
|
||||
|
||||
private static void WriteShort4(Vector4 value, Span<byte> 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<byte> target)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShort(value.x));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShort(value.y));
|
||||
}
|
||||
|
||||
private static void WriteUShort4(Vector4 value, Span<byte> 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<byte> target)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(target[..2], ToUShortNormalized(value.x));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(target.Slice(2, 2), ToUShortNormalized(value.y));
|
||||
}
|
||||
|
||||
private static void WriteUShort4Normalized(Vector4 value, Span<byte> 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<byte> 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<byte> 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<MdlStructs.VertexElement> 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<MdlStructs.VertexElement> 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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TaskRegistry<string> _decimationDeduplicator = new();
|
||||
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, byte> _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;
|
||||
|
||||
@@ -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<TextureCompressionRequest> requests,
|
||||
IProgress<TextureConversionProgress>? 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<TextureConversionProgress>? 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,20 +133,47 @@ 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
|
||||
{
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path);
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
|
||||
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
|
||||
private readonly SemaphoreSlim _compressionSemaphore = new(1);
|
||||
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
|
||||
new Dictionary<int, TextureCompressionTarget>
|
||||
{
|
||||
@@ -68,12 +72,14 @@ public sealed class TextureDownscaleService
|
||||
ILogger<TextureDownscaleService> 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<TextureMapKind> 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,17 +270,40 @@ 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;
|
||||
}
|
||||
|
||||
_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)
|
||||
{
|
||||
TryDelete(destination);
|
||||
@@ -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<bool> 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<string>(), 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<string>(), 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;
|
||||
|
||||
@@ -13,16 +13,20 @@ namespace LightlessSync.Services;
|
||||
public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
{
|
||||
private readonly List<WindowMediatorSubscriberBase> _createdWindows = [];
|
||||
private readonly List<WindowMediatorSubscriberBase> _registeredWindows = [];
|
||||
private readonly HashSet<WindowMediatorSubscriberBase> _uiHiddenWindows = [];
|
||||
private readonly IUiBuilder _uiBuilder;
|
||||
private readonly FileDialogManager _fileDialogManager;
|
||||
private readonly ILogger<UiService> _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<UiService> logger, IUiBuilder uiBuilder,
|
||||
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
|
||||
LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem,
|
||||
IEnumerable<WindowMediatorSubscriberBase> 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,7 +227,10 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
MainStyle.PushStyle();
|
||||
try
|
||||
{
|
||||
var hideOtherUi = ShouldHideOtherUi();
|
||||
UpdateUiHideState(hideOtherUi);
|
||||
_windowSystem.Draw();
|
||||
if (!hideOtherUi)
|
||||
_fileDialogManager.Draw();
|
||||
}
|
||||
finally
|
||||
@@ -227,4 +238,61 @@ public sealed class UiService : DisposableMediatorSubscriberBase
|
||||
MainStyle.PopStyle();
|
||||
}
|
||||
}
|
||||
|
||||
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<WindowMediatorSubscriberBase> EnumerateManagedWindows()
|
||||
{
|
||||
foreach (var window in _registeredWindows)
|
||||
{
|
||||
yield return window;
|
||||
}
|
||||
|
||||
foreach (var window in _createdWindows)
|
||||
{
|
||||
yield return window;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,14 @@ namespace MeshDecimator.Algorithms
|
||||
|
||||
private ResizableArray<Vector3> vertNormals = null;
|
||||
private ResizableArray<Vector4> vertTangents = null;
|
||||
private ResizableArray<Vector4> vertTangents2 = null;
|
||||
private UVChannels<Vector2> vertUV2D = null;
|
||||
private UVChannels<Vector3> vertUV3D = null;
|
||||
private UVChannels<Vector4> vertUV4D = null;
|
||||
private ResizableArray<Vector4> vertColors = null;
|
||||
private ResizableArray<BoneWeight> vertBoneWeights = null;
|
||||
private ResizableArray<float> vertPositionWs = null;
|
||||
private ResizableArray<float> 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;
|
||||
|
||||
51
LightlessSync/ThirdParty/MeshDecimator/Mesh.cs
vendored
51
LightlessSync/ThirdParty/MeshDecimator/Mesh.cs
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position W components for this mesh.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normal W components for this mesh.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tangents for this mesh.
|
||||
/// </summary>
|
||||
@@ -183,6 +216,21 @@ namespace MeshDecimator
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the second tangent set for this mesh.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the first UV set for this mesh.
|
||||
/// </summary>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -22,13 +22,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
|
||||
private readonly ApiController _apiController;
|
||||
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
|
||||
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
|
||||
private readonly HashSet<string> _onlinePairBuffer = new(StringComparer.Ordinal);
|
||||
private IImmutableList<DrawUserPair>? _drawPairsCache;
|
||||
private int? _totalPairsCache;
|
||||
private bool _wasHovered = false;
|
||||
private float _menuWidth;
|
||||
private bool _rowClickArmed;
|
||||
|
||||
public IImmutableList<DrawUserPair> 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<DrawUserPair> 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<GroupFolder> 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<DrawUserPair> 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)
|
||||
|
||||
@@ -340,7 +340,10 @@ public class DrawUserPair
|
||||
? FontAwesomeIcon.User : FontAwesomeIcon.Users);
|
||||
}
|
||||
|
||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||
{
|
||||
UiSharedService.AttachToolTip(GetUserTooltip());
|
||||
}
|
||||
|
||||
if (_performanceConfigService.Current.ShowPerformanceIndicator
|
||||
&& !_performanceConfigService.Current.UIDsToIgnore
|
||||
@@ -354,6 +357,8 @@ public class DrawUserPair
|
||||
|
||||
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow"));
|
||||
|
||||
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
|
||||
{
|
||||
string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator;
|
||||
bool shownVram = false;
|
||||
if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0
|
||||
@@ -371,6 +376,7 @@ public class DrawUserPair
|
||||
|
||||
UiSharedService.AttachToolTip(userWarningText);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui.SameLine();
|
||||
|
||||
@@ -613,12 +619,15 @@ public class DrawUserPair
|
||||
perm.SetPaused(!perm.IsPaused());
|
||||
_ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm));
|
||||
}
|
||||
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);
|
||||
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));
|
||||
|
||||
930
LightlessSync/UI/Components/OptimizationSettingsPanel.cs
Normal file
930
LightlessSync/UI/Components/OptimizationSettingsPanel.cs
Normal file
@@ -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<string, Vector4, bool> 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<Pair> 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<Pair> 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";
|
||||
}
|
||||
}
|
||||
789
LightlessSync/UI/Components/OptimizationSummaryCard.cs
Normal file
789
LightlessSync/UI/Components/OptimizationSummaryCard.cs
Normal file
@@ -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<Pair> 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<OptimizationTooltipLine>();
|
||||
|
||||
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<OptimizationTooltipLine>();
|
||||
|
||||
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<string>();
|
||||
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<OptimizationTooltipLine> 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<OptimizationTooltipLine> 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<OptimizationTooltipLine> 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, double> _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<OpenPerformanceSettingsMessage>(this, msg =>
|
||||
{
|
||||
IsOpen = true;
|
||||
FocusPerformanceSection(msg.Section);
|
||||
});
|
||||
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
|
||||
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
|
||||
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
|
||||
@@ -516,162 +530,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTextureDownscaleCounters()
|
||||
{
|
||||
HashSet<Pair> 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<Pair> 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<string> GetGroupInfoFlags(GroupPairUserInfo info)
|
||||
{
|
||||
if (info.HasFlag(GroupPairUserInfo.IsModerator))
|
||||
@@ -1735,23 +1746,28 @@ public class SettingsUi : WindowMediatorSubscriberBase
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawPairEventLog(Pair pair)
|
||||
private List<Event> 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));
|
||||
|
||||
@@ -40,9 +40,9 @@ 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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
81
LightlessSync/Utils/TaskRegistry.cs
Normal file
81
LightlessSync/Utils/TaskRegistry.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
|
||||
namespace LightlessSync.Utils;
|
||||
|
||||
public sealed class TaskRegistry<HandleType> where HandleType : notnull
|
||||
{
|
||||
private readonly ConcurrentDictionary<HandleType, ActiveTask> _activeTasks = new();
|
||||
|
||||
public Task GetOrStart(HandleType handle, Func<Task> taskFactory)
|
||||
{
|
||||
ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory)));
|
||||
return entry.EnsureStarted();
|
||||
}
|
||||
|
||||
public Task<T> GetOrStart<T>(HandleType handle, Func<Task<T>> taskFactory)
|
||||
{
|
||||
ActiveTask entry = _activeTasks.GetOrAdd(handle, i => new ActiveTask(() => ExecuteAndRemove(i, taskFactory)));
|
||||
return (Task<T>)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<Task> taskFactory)
|
||||
{
|
||||
try
|
||||
{
|
||||
await taskFactory().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activeTasks.TryRemove(handle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteAndRemove<T>(HandleType handle, Func<Task<T>> taskFactory)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await taskFactory().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_activeTasks.TryRemove(handle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ActiveTask
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Func<Task> _starter;
|
||||
private Task? _cached;
|
||||
|
||||
public ActiveTask(Func<Task> starter)
|
||||
{
|
||||
_starter = starter;
|
||||
}
|
||||
|
||||
public Task EnsureStarted()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_cached == null || _cached.IsCompleted)
|
||||
{
|
||||
_cached = _starter();
|
||||
}
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs
Normal file
48
LightlessSync/WebAPI/Files/FileDownloadDeduplicator.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LightlessSync.WebAPI.Files;
|
||||
|
||||
public readonly record struct DownloadClaim(bool IsOwner, Task<bool> Completion);
|
||||
|
||||
public sealed class FileDownloadDeduplicator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> _inFlight =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public DownloadClaim Claim(string hash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hash))
|
||||
{
|
||||
return new DownloadClaim(false, Task.FromResult(true));
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user