sigma update

This commit is contained in:
2026-01-16 11:00:58 +09:00
parent 59ed03a825
commit 96123d00a2
51 changed files with 6640 additions and 1382 deletions

View 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;
}

View File

@@ -1,6 +1,4 @@
using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Compactor;
using LightlessSync.Services;
using LightlessSync.Services.Compactor;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Win32.SafeHandles; using Microsoft.Win32.SafeHandles;
using System.Collections.Concurrent; using System.Collections.Concurrent;
@@ -20,8 +18,8 @@ public sealed partial class FileCompactor : IDisposable
private readonly ConcurrentDictionary<string, byte> _pendingCompactions; private readonly ConcurrentDictionary<string, byte> _pendingCompactions;
private readonly ILogger<FileCompactor> _logger; private readonly ILogger<FileCompactor> _logger;
private readonly LightlessConfigService _lightlessConfigService; private readonly ICompactorContext _context;
private readonly DalamudUtilService _dalamudUtilService; private readonly ICompactionExecutor _compactionExecutor;
private readonly Channel<string> _compactionQueue; private readonly Channel<string> _compactionQueue;
private readonly CancellationTokenSource _compactionCts = new(); private readonly CancellationTokenSource _compactionCts = new();
@@ -59,12 +57,12 @@ public sealed partial class FileCompactor : IDisposable
XPRESS16K = 3 XPRESS16K = 3
} }
public FileCompactor(ILogger<FileCompactor> logger, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService) public FileCompactor(ILogger<FileCompactor> logger, ICompactorContext context, ICompactionExecutor compactionExecutor)
{ {
_pendingCompactions = new(StringComparer.OrdinalIgnoreCase); _pendingCompactions = new(StringComparer.OrdinalIgnoreCase);
_logger = logger; _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_lightlessConfigService = lightlessConfigService; _context = context ?? throw new ArgumentNullException(nameof(context));
_dalamudUtilService = dalamudUtilService; _compactionExecutor = compactionExecutor ?? throw new ArgumentNullException(nameof(compactionExecutor));
_isWindows = OperatingSystem.IsWindows(); _isWindows = OperatingSystem.IsWindows();
_compactionQueue = Channel.CreateUnbounded<string>(new UnboundedChannelOptions _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 //Uses an batching service for the filefrag command on Linux
_fragBatch = new BatchFilefragService( _fragBatch = new BatchFilefragService(
useShell: _dalamudUtilService.IsWine, useShell: _context.IsWine,
log: _logger, log: _logger,
batchSize: 64, batchSize: 64,
flushMs: 25, flushMs: 25,
@@ -118,7 +116,7 @@ public sealed partial class FileCompactor : IDisposable
try try
{ {
var folder = _lightlessConfigService.Current.CacheFolder; var folder = _context.CacheFolder;
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
{ {
if (_logger.IsEnabled(LogLevel.Warning)) if (_logger.IsEnabled(LogLevel.Warning))
@@ -127,7 +125,7 @@ public sealed partial class FileCompactor : IDisposable
return; return;
} }
var files = Directory.EnumerateFiles(folder).ToArray(); var files = Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToArray();
var total = files.Length; var total = files.Length;
Progress = $"0/{total}"; Progress = $"0/{total}";
if (total == 0) return; if (total == 0) return;
@@ -155,7 +153,7 @@ public sealed partial class FileCompactor : IDisposable
{ {
if (compress) if (compress)
{ {
if (_lightlessConfigService.Current.UseCompactor) if (_context.UseCompactor)
CompactFile(file, workerId); CompactFile(file, workerId);
} }
else else
@@ -221,19 +219,52 @@ public sealed partial class FileCompactor : IDisposable
await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false); await File.WriteAllBytesAsync(filePath, bytes, token).ConfigureAwait(false);
if (_lightlessConfigService.Current.UseCompactor) if (_context.UseCompactor)
EnqueueCompaction(filePath); 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> /// <summary>
/// Gets the File size for an BTRFS or NTFS file system for the given FileInfo /// Gets the File size for an BTRFS or NTFS file system for the given FileInfo
/// </summary> /// </summary>
/// <param name="path">Amount of blocks used in the disk</param> /// <param name="path">Amount of blocks used in the disk</param>
public long GetFileSizeOnDisk(FileInfo fileInfo) 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); (bool flowControl, long value) = GetFileSizeNTFS(fileInfo);
if (!flowControl) if (!flowControl)
@@ -290,7 +321,7 @@ public sealed partial class FileCompactor : IDisposable
{ {
try try
{ {
var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _dalamudUtilService.IsWine); var blockSize = GetBlockSizeForPath(fileInfo.FullName, _logger, _context.IsWine);
if (blockSize <= 0) if (blockSize <= 0)
throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}"); throw new InvalidOperationException($"Invalid block size {blockSize} for {fileInfo.FullName}");
@@ -330,7 +361,7 @@ public sealed partial class FileCompactor : IDisposable
return; return;
} }
var fsType = GetFilesystemType(filePath, _dalamudUtilService.IsWine); var fsType = GetFilesystemType(filePath, _context.IsWine);
var oldSize = fi.Length; var oldSize = fi.Length;
int blockSize = (int)(GetFileSizeOnDisk(fi) / 512); int blockSize = (int)(GetFileSizeOnDisk(fi) / 512);
@@ -346,7 +377,7 @@ public sealed partial class FileCompactor : IDisposable
return; return;
} }
if (fsType == FilesystemType.NTFS && !_dalamudUtilService.IsWine) if (fsType == FilesystemType.NTFS && !_context.IsWine)
{ {
if (!IsWOFCompactedFile(filePath)) if (!IsWOFCompactedFile(filePath))
{ {
@@ -402,9 +433,9 @@ public sealed partial class FileCompactor : IDisposable
private void DecompressFile(string filePath, int workerId) private void DecompressFile(string filePath, int workerId)
{ {
_logger.LogDebug("[W{worker}] Decompress request: {file}", workerId, filePath); _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 try
{ {
@@ -448,7 +479,7 @@ public sealed partial class FileCompactor : IDisposable
{ {
try try
{ {
bool isWine = _dalamudUtilService?.IsWine ?? false; bool isWine = _context.IsWine;
string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path; string linuxPath = isWine ? ToLinuxPathIfWine(path, isWine) : path;
var opts = GetMountOptionsForPath(linuxPath); var opts = GetMountOptionsForPath(linuxPath);
@@ -961,7 +992,7 @@ public sealed partial class FileCompactor : IDisposable
if (finished != bothTasks) if (finished != bothTasks)
return KillProcess(proc, outTask, errTask, token); return KillProcess(proc, outTask, errTask, token);
bool isWine = _dalamudUtilService?.IsWine ?? false; bool isWine = _context.IsWine;
if (!isWine) if (!isWine)
{ {
try { proc.WaitForExit(); } catch { /* ignore quirks */ } try { proc.WaitForExit(); } catch { /* ignore quirks */ }
@@ -1005,7 +1036,7 @@ public sealed partial class FileCompactor : IDisposable
if (string.IsNullOrWhiteSpace(filePath)) if (string.IsNullOrWhiteSpace(filePath))
return; return;
if (!_lightlessConfigService.Current.UseCompactor) if (!_context.UseCompactor)
return; return;
if (!File.Exists(filePath)) if (!File.Exists(filePath))
@@ -1017,7 +1048,7 @@ public sealed partial class FileCompactor : IDisposable
bool enqueued = false; bool enqueued = false;
try try
{ {
bool isWine = _dalamudUtilService?.IsWine ?? false; bool isWine = _context.IsWine;
var fsType = GetFilesystemType(filePath, isWine); var fsType = GetFilesystemType(filePath, isWine);
// If under Wine, we should skip NTFS because its not Windows but might return NTFS. // 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 try
{ {
if (_lightlessConfigService.Current.UseCompactor && File.Exists(filePath)) if (_context.UseCompactor && File.Exists(filePath))
{
if (!_compactionExecutor.TryCompact(filePath))
CompactFile(filePath, workerId); CompactFile(filePath, workerId);
} }
}
finally finally
{ {
_globalGate.Release(); _globalGate.Release();

View 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>

View 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>

View 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; }
}
}

View File

@@ -22,6 +22,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OtterGui", "OtterGui\OtterG
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64 {825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View 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; }
}
}

View File

@@ -115,6 +115,35 @@ public sealed class FileCacheManager : IHostedService
return true; 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 string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}";
private static bool TryParseVersionHeader(string? line, out int version) 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); _logger.LogTrace("Creating cache entry for {path}", path);
var cacheFolder = _configService.Current.CacheFolder; var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder)) return null; if (string.IsNullOrEmpty(cacheFolder)) return null;
if (TryGetHashFromFileName(fi, out var hash))
{
return CreateCacheEntryWithKnownHash(fi.FullName, hash);
}
return CreateFileEntity(cacheFolder, CachePrefix, fi); return CreateFileEntity(cacheFolder, CachePrefix, fi);
} }

View 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;
}

View File

@@ -25,7 +25,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
private readonly object _ownedHandlerLock = new(); private readonly object _ownedHandlerLock = new();
private readonly string[] _handledFileTypes = ["tmb", "pap", "avfx", "atex", "sklb", "eid", "phyb", "scd", "skp", "shpk", "kdb"]; 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[] _handledRecordingFileTypes = ["tex", "mdl", "mtrl"];
private readonly string[] _handledFileTypesWithRecording;
private readonly HashSet<GameObjectHandler> _playerRelatedPointers = []; private readonly HashSet<GameObjectHandler> _playerRelatedPointers = [];
private readonly object _playerRelatedLock = new(); private readonly object _playerRelatedLock = new();
private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new(); private readonly ConcurrentDictionary<nint, GameObjectHandler> _playerRelatedByAddress = new();
@@ -42,8 +41,6 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_dalamudUtil = dalamudUtil; _dalamudUtil = dalamudUtil;
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_gameObjectHandlerFactory = gameObjectHandlerFactory; _gameObjectHandlerFactory = gameObjectHandlerFactory;
_handledFileTypesWithRecording = _handledRecordingFileTypes.Concat(_handledFileTypes).ToArray();
Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent); Mediator.Subscribe<PenumbraResourceLoadMessage>(this, Manager_PenumbraResourceLoadEvent);
Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor)); Mediator.Subscribe<ActorTrackedMessage>(this, msg => HandleActorTracked(msg.Descriptor));
Mediator.Subscribe<ActorUntrackedMessage>(this, msg => HandleActorUntracked(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) private void Manager_PenumbraResourceLoadEvent(PenumbraResourceLoadMessage msg)
{ {
var gamePath = msg.GamePath.ToLowerInvariant();
var gameObjectAddress = msg.GameObject; var gameObjectAddress = msg.GameObject;
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind)) var filePath = msg.FilePath;
{
if (_actorObjectService.TryGetOwnedKind(gameObjectAddress, out var ownedKind))
{
objectKind = ownedKind;
}
else
{
return;
}
}
var gamePath = NormalizeGamePath(msg.GamePath);
if (string.IsNullOrEmpty(gamePath))
{
return;
}
// ignore files already processed this frame // ignore files already processed this frame
if (_cachedHandledPaths.Contains(gamePath)) return;
lock (_cacheAdditionLock) 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; return;
} }
}
// ignore files to not handle // ignore files to not handle
var handledTypes = IsTransientRecording ? _handledFileTypesWithRecording : _handledFileTypes; var handledTypes = IsTransientRecording ? _handledRecordingFileTypes.Concat(_handledFileTypes) : _handledFileTypes;
if (!HasHandledFileType(gamePath, handledTypes)) if (!handledTypes.Any(type => gamePath.EndsWith(type, StringComparison.OrdinalIgnoreCase)))
{ {
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return; return;
} }
var filePath = NormalizeFilePath(msg.FilePath); // ignore files not belonging to anything player related
if (!_cachedFrameAddresses.TryGetValue(gameObjectAddress, out var objectKind))
// ignore files that are the same
if (string.Equals(filePath, gamePath, StringComparison.Ordinal))
{ {
lock (_cacheAdditionLock)
{
_cachedHandledPaths.Add(gamePath);
}
return; return;
} }
@@ -577,12 +579,13 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
_playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner); _playerRelatedByAddress.TryGetValue(gameObjectAddress, out var owner);
bool alreadyTransient = false; bool alreadyTransient = false;
bool transientContains = transientResources.Contains(gamePath); bool transientContains = transientResources.Contains(replacedGamePath);
bool semiTransientContains = SemiTransientResources.Values.Any(value => value.Contains(gamePath)); bool semiTransientContains = SemiTransientResources.SelectMany(k => k.Value)
.Any(f => string.Equals(f, gamePath, StringComparison.OrdinalIgnoreCase));
if (transientContains || semiTransientContains) if (transientContains || semiTransientContains)
{ {
if (!IsTransientRecording) 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); transientContains, semiTransientContains);
alreadyTransient = true; alreadyTransient = true;
} }
@@ -590,10 +593,10 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
{ {
if (!IsTransientRecording) if (!IsTransientRecording)
{ {
bool isAdded = transientResources.Add(gamePath); bool isAdded = transientResources.Add(replacedGamePath);
if (isAdded) 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); SendTransients(gameObjectAddress, objectKind);
} }
} }
@@ -601,7 +604,7 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase
if (owner != null && IsTransientRecording) if (owner != null && IsTransientRecording)
{ {
_recordedTransients.Add(new TransientRecord(owner, gamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient }); _recordedTransients.Add(new TransientRecord(owner, replacedGamePath, filePath, alreadyTransient) { AddTransient = !alreadyTransient });
} }
} }

View File

@@ -4,7 +4,6 @@ using LightlessSync.Interop.Ipc.Penumbra;
using LightlessSync.LightlessConfiguration.Models; using LightlessSync.LightlessConfiguration.Models;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
@@ -36,8 +35,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
IDalamudPluginInterface pluginInterface, IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil, DalamudUtilService dalamudUtil,
LightlessMediator mediator, LightlessMediator mediator,
RedrawManager redrawManager, RedrawManager redrawManager) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
ActorObjectService actorObjectService) : base(logger, mediator, pluginInterface, PenumbraDescriptor)
{ {
_penumbraEnabled = new GetEnabledState(pluginInterface); _penumbraEnabled = new GetEnabledState(pluginInterface);
_penumbraGetModDirectory = new GetModDirectory(pluginInterface); _penumbraGetModDirectory = new GetModDirectory(pluginInterface);
@@ -46,7 +44,7 @@ public sealed class IpcCallerPenumbra : IpcServiceBase
_penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged); _penumbraModSettingChanged = ModSettingChanged.Subscriber(pluginInterface, HandlePenumbraModSettingChanged);
_collections = RegisterInterop(new PenumbraCollections(logger, pluginInterface, dalamudUtil, mediator)); _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)); _redraw = RegisterInterop(new PenumbraRedraw(logger, pluginInterface, dalamudUtil, mediator, redrawManager));
_textures = RegisterInterop(new PenumbraTexture(logger, pluginInterface, dalamudUtil, mediator, _redraw)); _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) public Task RedrawAsync(ILogger logger, GameObjectHandler handler, Guid applicationId, CancellationToken token)
=> _redraw.RedrawAsync(logger, handler, applicationId, token); => _redraw.RedrawAsync(logger, handler, applicationId, token);
public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token) public Task ConvertTextureFiles(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token, bool requestRedraw = true)
=> _textures.ConvertTextureFilesAsync(logger, jobs, progress, token); => _textures.ConvertTextureFilesAsync(logger, jobs, progress, token, requestRedraw);
public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token) public Task ConvertTextureFileDirectAsync(TextureConversionJob job, CancellationToken token)
=> _textures.ConvertTextureFileDirectAsync(job, token); => _textures.ConvertTextureFileDirectAsync(job, token);

View File

@@ -2,9 +2,9 @@ using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework; using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers; using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services; using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Globalization;
using Penumbra.Api.Helpers; using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers; using Penumbra.Api.IpcSubscribers;
@@ -12,7 +12,6 @@ namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraResource : PenumbraBase public sealed class PenumbraResource : PenumbraBase
{ {
private readonly ActorObjectService _actorObjectService;
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths; private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
private readonly ResolveGameObjectPath _resolveGameObjectPath; private readonly ResolveGameObjectPath _resolveGameObjectPath;
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath; private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
@@ -24,10 +23,8 @@ public sealed class PenumbraResource : PenumbraBase
ILogger logger, ILogger logger,
IDalamudPluginInterface pluginInterface, IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil, DalamudUtilService dalamudUtil,
LightlessMediator mediator, LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator)
{ {
_actorObjectService = actorObjectService;
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface); _gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface); _resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface); _reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
@@ -79,23 +76,11 @@ public sealed class PenumbraResource : PenumbraBase
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath) 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)); Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
} }
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current) protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{ {

View File

@@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase
public override string Name => "Penumbra.Textures"; 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) if (!IsAvailable || jobs.Count == 0)
{ {
@@ -57,7 +57,7 @@ public sealed class PenumbraTexture : PenumbraBase
Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync))); Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync)));
} }
if (completedJobs > 0 && !token.IsCancellationRequested) if (requestRedraw && completedJobs > 0 && !token.IsCancellationRequested)
{ {
await DalamudUtil.RunOnFrameworkThread(async () => await DalamudUtil.RunOnFrameworkThread(async () =>
{ {

View File

@@ -12,6 +12,9 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool ShowMessageTimestamps { get; set; } = true; public bool ShowMessageTimestamps { get; set; } = true;
public bool ShowNotesInSyncshellChat { get; set; } = true; public bool ShowNotesInSyncshellChat { get; set; } = true;
public bool EnableAnimatedEmotes { 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 float ChatWindowOpacity { get; set; } = .97f;
public bool FadeWhenUnfocused { get; set; } = false; public bool FadeWhenUnfocused { get; set; } = false;
public float UnfocusedWindowOpacity { get; set; } = 0.6f; public float UnfocusedWindowOpacity { get; set; } = 0.6f;
@@ -23,6 +26,9 @@ public sealed class ChatConfig : ILightlessConfiguration
public bool ShowWhenUiHidden { get; set; } = true; public bool ShowWhenUiHidden { get; set; } = true;
public bool ShowInCutscenes { get; set; } = true; public bool ShowInCutscenes { get; set; } = true;
public bool ShowInGpose { get; set; } = true; public bool ShowInGpose { get; set; } = true;
public bool PersistSyncshellHistory { get; set; } = false;
public List<string> ChannelOrder { get; set; } = new(); 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); public Dictionary<string, bool> PreferNotesForChannels { get; set; } = new(StringComparer.Ordinal);
} }

View File

@@ -32,6 +32,8 @@ public class LightlessConfig : ILightlessConfiguration
public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u); public DtrEntry.Colors DtrColorsLightfinderUnavailable { get; set; } = new(Foreground: 0x000000u, Glow: 0x000000u);
public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests; public LightfinderDtrDisplayMode LightfinderDtrDisplayMode { get; set; } = LightfinderDtrDisplayMode.PendingPairRequests;
public bool UseLightlessRedesign { get; set; } = true; 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 bool EnableRightClickMenus { get; set; } = true;
public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both; public NotificationLocation ErrorNotification { get; set; } = NotificationLocation.Both;
public string ExportFolder { get; set; } = string.Empty; public string ExportFolder { get; set; } = string.Empty;

View File

@@ -21,11 +21,14 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool EnableIndexTextureDownscale { get; set; } = false; public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048; public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true; 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 KeepOriginalTextureFiles { get; set; } = false;
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true; public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
public bool EnableModelDecimation { get; set; } = false; 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 double ModelDecimationTargetRatio { get; set; } = 0.8;
public bool ModelDecimationNormalizeTangents { get; set; } = true;
public bool KeepOriginalModelFiles { get; set; } = true; public bool KeepOriginalModelFiles { get; set; } = true;
public bool SkipModelDecimationForPreferredPairs { get; set; } = true; public bool SkipModelDecimationForPreferredPairs { get; set; } = true;
public bool ModelDecimationAllowBody { get; set; } = false; public bool ModelDecimationAllowBody { get; set; } = false;

View File

@@ -78,6 +78,8 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\LightlessAPI\LightlessSyncAPI\LightlessSync.API.csproj" /> <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.Api\Penumbra.Api.csproj" />
<ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" /> <ProjectReference Include="..\Penumbra.GameData\Penumbra.GameData.csproj" />
<ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" /> <ProjectReference Include="..\Penumbra.String\Penumbra.String.csproj" />
@@ -102,4 +104,12 @@
<PackageReference Update="DalamudPackager" Version="14.0.1" /> <PackageReference Update="DalamudPackager" Version="14.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<CompactorWorkerFiles Include="..\LightlessCompactorWorker\bin\$(Configuration)\net10.0\*.*" />
</ItemGroup>
<Target Name="CopyCompactorWorker" AfterTargets="Build">
<Copy SourceFiles="@(CompactorWorkerFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" />
</Target>
</Project> </Project>

View File

@@ -19,6 +19,7 @@ public class FileDownloadManagerFactory
private readonly TextureDownscaleService _textureDownscaleService; private readonly TextureDownscaleService _textureDownscaleService;
private readonly ModelDecimationService _modelDecimationService; private readonly ModelDecimationService _modelDecimationService;
private readonly TextureMetadataHelper _textureMetadataHelper; private readonly TextureMetadataHelper _textureMetadataHelper;
private readonly FileDownloadDeduplicator _downloadDeduplicator;
public FileDownloadManagerFactory( public FileDownloadManagerFactory(
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
@@ -29,7 +30,8 @@ public class FileDownloadManagerFactory
LightlessConfigService configService, LightlessConfigService configService,
TextureDownscaleService textureDownscaleService, TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService, ModelDecimationService modelDecimationService,
TextureMetadataHelper textureMetadataHelper) TextureMetadataHelper textureMetadataHelper,
FileDownloadDeduplicator downloadDeduplicator)
{ {
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
_lightlessMediator = lightlessMediator; _lightlessMediator = lightlessMediator;
@@ -40,6 +42,7 @@ public class FileDownloadManagerFactory
_textureDownscaleService = textureDownscaleService; _textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService; _modelDecimationService = modelDecimationService;
_textureMetadataHelper = textureMetadataHelper; _textureMetadataHelper = textureMetadataHelper;
_downloadDeduplicator = downloadDeduplicator;
} }
public FileDownloadManager Create() public FileDownloadManager Create()
@@ -53,6 +56,7 @@ public class FileDownloadManagerFactory
_configService, _configService,
_textureDownscaleService, _textureDownscaleService,
_modelDecimationService, _modelDecimationService,
_textureMetadataHelper); _textureMetadataHelper,
_downloadDeduplicator);
} }
} }

View File

@@ -476,7 +476,7 @@ public class PlayerDataFactory
if (transientPaths.Count == 0) if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements); 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); .ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries) if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
@@ -692,7 +692,6 @@ public class PlayerDataFactory
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths( private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
GameObjectHandler handler,
HashSet<string> forwardResolve, HashSet<string> forwardResolve,
HashSet<string> reverseResolve) HashSet<string> reverseResolve)
{ {
@@ -707,59 +706,6 @@ public class PlayerDataFactory
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray(); 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); 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); var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++) for (int i = 0; i < forwardPaths.Length; i++)

View File

@@ -1,12 +1,12 @@
using LightlessSync.API.Data; using LightlessSync.API.Data;
namespace LightlessSync.PlayerData.Pairs; namespace LightlessSync.PlayerData.Pairs;
/// <summary> /// <summary>
/// orchestrates the lifecycle of a paired character /// orchestrates the lifecycle of a paired character
/// </summary> /// </summary>
public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject public interface IPairHandlerAdapter : IDisposable, IPairPerformanceSubject
{ {
new string Ident { get; } new string Ident { get; }
bool Initialized { get; } bool Initialized { get; }
bool IsVisible { get; } bool IsVisible { get; }
@@ -36,8 +36,9 @@
void Initialize(); void Initialize();
void ApplyData(CharacterData data); void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false); void ApplyLastReceivedData(bool forced = false);
Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken);
bool FetchPerformanceMetricsFromCache(); bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data); void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading); void SetUploading(bool uploading);
void SetPaused(bool paused); void SetPaused(bool paused);
} }

View File

@@ -54,6 +54,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly XivDataAnalyzer _modelAnalyzer; private readonly XivDataAnalyzer _modelAnalyzer;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor; private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly SemaphoreSlim _metricsComputeGate = new(1, 1);
private readonly PairManager _pairManager; private readonly PairManager _pairManager;
private readonly IFramework _framework; private readonly IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource; private CancellationTokenSource? _applicationCancellationTokenSource;
@@ -193,8 +194,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
public string? LastFailureReason => _lastFailureReason; public string? LastFailureReason => _lastFailureReason;
public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions; public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions;
public bool IsApplying => _applicationTask is { IsCompleted: false }; public bool IsApplying => _applicationTask is { IsCompleted: false };
public bool IsDownloading => _downloadManager.IsDownloading; public bool IsDownloading => _downloadManager.IsDownloadingFor(_charaHandler);
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count; public int PendingDownloadCount => _downloadManager.GetPendingDownloadCount(_charaHandler);
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count; public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
public PairHandlerAdapter( public PairHandlerAdapter(
@@ -721,6 +722,74 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return true; 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) private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash)
{ {
dataHash = null; 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); 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); SetUploading(false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods); 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"))); "Applying Character Data")));
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this, var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this,
forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw); forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply);
if (handlerReady && _forceApplyMods) if (handlerReady && _forceApplyMods)
{ {
@@ -1921,7 +2003,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
var handlerForDownload = _charaHandler; 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); await _pairDownloadTask.ConfigureAwait(false);
@@ -2136,6 +2218,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
} }
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly); SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
var hasPap = papOnly.Count > 0;
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false); 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) if (handlerForApply.Address != nint.Zero)
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false); await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
if (hasPap)
{
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false); var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
if (removedPap > 0) if (removedPap > 0)
{ {
@@ -2164,6 +2249,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
.ConfigureAwait(false); .ConfigureAwait(false);
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer); _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; LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) 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) => (item) =>
{ {
token.ThrowIfCancellationRequested(); 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); var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath)) if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath))
{ {

View File

@@ -271,7 +271,20 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
try 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) catch (Exception ex)
{ {

View File

@@ -129,12 +129,15 @@ public sealed class Plugin : IDalamudPlugin
services.AddSingleton<TextureDownscaleService>(); services.AddSingleton<TextureDownscaleService>();
services.AddSingleton<ModelDecimationService>(); services.AddSingleton<ModelDecimationService>();
services.AddSingleton<GameObjectHandlerFactory>(); services.AddSingleton<GameObjectHandlerFactory>();
services.AddSingleton<FileDownloadDeduplicator>();
services.AddSingleton<FileDownloadManagerFactory>(); services.AddSingleton<FileDownloadManagerFactory>();
services.AddSingleton<PairProcessingLimiter>(); services.AddSingleton<PairProcessingLimiter>();
services.AddSingleton<XivDataAnalyzer>(); services.AddSingleton<XivDataAnalyzer>();
services.AddSingleton<CharacterAnalyzer>(); services.AddSingleton<CharacterAnalyzer>();
services.AddSingleton<TokenProvider>(); services.AddSingleton<TokenProvider>();
services.AddSingleton<PluginWarningNotificationService>(); services.AddSingleton<PluginWarningNotificationService>();
services.AddSingleton<ICompactorContext, PluginCompactorContext>();
services.AddSingleton<ICompactionExecutor, ExternalCompactionExecutor>();
services.AddSingleton<FileCompactor>(); services.AddSingleton<FileCompactor>();
services.AddSingleton<TagHandler>(); services.AddSingleton<TagHandler>();
services.AddSingleton<PairRequestService>(); services.AddSingleton<PairRequestService>();
@@ -331,8 +334,7 @@ public sealed class Plugin : IDalamudPlugin
pluginInterface, pluginInterface,
sp.GetRequiredService<DalamudUtilService>(), sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<LightlessMediator>(), sp.GetRequiredService<LightlessMediator>(),
sp.GetRequiredService<RedrawManager>(), sp.GetRequiredService<RedrawManager>()));
sp.GetRequiredService<ActorObjectService>()));
services.AddSingleton(sp => new IpcCallerGlamourer( services.AddSingleton(sp => new IpcCallerGlamourer(
sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(), sp.GetRequiredService<ILogger<IpcCallerGlamourer>>(),
@@ -516,6 +518,7 @@ public sealed class Plugin : IDalamudPlugin
sp.GetRequiredService<ILogger<UiService>>(), sp.GetRequiredService<ILogger<UiService>>(),
pluginInterface.UiBuilder, pluginInterface.UiBuilder,
sp.GetRequiredService<LightlessConfigService>(), sp.GetRequiredService<LightlessConfigService>(),
sp.GetRequiredService<DalamudUtilService>(),
sp.GetRequiredService<WindowSystem>(), sp.GetRequiredService<WindowSystem>(),
sp.GetServices<WindowMediatorSubscriberBase>(), sp.GetServices<WindowMediatorSubscriberBase>(),
sp.GetRequiredService<UiFactory>(), sp.GetRequiredService<UiFactory>(),

View File

@@ -571,36 +571,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (localPlayerAddress == nint.Zero) if (localPlayerAddress == nint.Zero)
return nint.Zero; return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0) if (ownerEntityId == 0)
return nint.Zero; 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 candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind; var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion) return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion
{ ? candidateAddress
if (ResolveOwnerId(candidate) == ownerEntityId) : nint.Zero;
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;
} }
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId) 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; return nint.Zero;
} }
@@ -655,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return candidate; 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; return nint.Zero;
} }

View File

@@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using LightlessSync.UI.Services; using LightlessSync.UI.Services;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LightlessSync.Services.Chat; namespace LightlessSync.Services.Chat;
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
{ {
private const int MaxMessageHistory = 150; private const int MaxMessageHistory = 200;
internal const int MaxOutgoingLength = 200; internal const int MaxOutgoingLength = 200;
private const int MaxUnreadCount = 999; private const int MaxUnreadCount = 999;
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities."; private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
private const string ZoneChannelKey = "zone"; private const string ZoneChannelKey = "zone";
private const int MaxReportReasonLength = 100; private const int MaxReportReasonLength = 100;
private const int MaxReportContextLength = 1000; private const int MaxReportContextLength = 1000;
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtilService; private readonly DalamudUtilService _dalamudUtilService;
@@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
public Task StartAsync(CancellationToken cancellationToken) public Task StartAsync(CancellationToken cancellationToken)
{ {
LoadPersistedSyncshellHistory();
Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin()); Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin());
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout()); Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate()); Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
@@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private void OnChatMessageReceived(ChatMessageDto dto) private void OnChatMessageReceived(ChatMessageDto dto)
{ {
var descriptor = dto.Channel.WithNormalizedCustomKey(); ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor); string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
var fromSelf = IsMessageFromSelf(dto, key); bool fromSelf = IsMessageFromSelf(dto, key);
var message = BuildMessage(dto, fromSelf); 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 publishChannelList = false;
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
List<PersistedChatMessage>? persistedMessages = null;
string? persistedChannelKey = null;
using (_sync.EnterScope()) using (_sync.EnterScope())
{ {
@@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.Messages.RemoveAt(0); state.Messages.RemoveAt(0);
} }
if (notifyMention)
{
mentionChannelName = state.DisplayName;
mentionSenderName = message.DisplayName;
}
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal)) if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{ {
state.HasUnread = false; state.HasUnread = false;
@@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
} }
MarkChannelsSnapshotDirtyLocked(); MarkChannelsSnapshotDirtyLocked();
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
{
persistedChannelKey = state.Key;
persistedMessages = BuildPersistedHistoryLocked(state);
}
} }
Mediator.Publish(new ChatChannelMessageAdded(key, message)); 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) if (publishChannelList)
{ {
using (_sync.EnterScope()) using (_sync.EnterScope())
@@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return false; 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) private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
{ {
var displayName = ResolveDisplayName(dto, fromSelf); var displayName = ResolveDisplayName(dto, fromSelf);
@@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return 0; 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 private sealed class ChatChannelState
{ {
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor) public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
@@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
bool IsOwner); bool IsOwner);
private readonly record struct PendingSelfMessage(string ChannelKey, string Message); 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);
} }

View File

@@ -424,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (playerPointer == IntPtr.Zero) return IntPtr.Zero; if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
var playerAddress = playerPointer.Value; var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId; return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
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;
} }
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null) 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) public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
@@ -493,69 +462,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false); 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) private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
{ {
if (candidate == null) if (candidate == null)

View File

@@ -21,6 +21,12 @@ public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase; public record SwitchToMainUiMessage : MessageBase;
public record OpenSettingsUiMessage : MessageBase; public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase; public record OpenLightfinderSettingsMessage : MessageBase;
public enum PerformanceSettingsSection
{
TextureOptimization,
ModelOptimization,
}
public record OpenPerformanceSettingsMessage(PerformanceSettingsSection Section) : MessageBase;
public record DalamudLoginMessage : MessageBase; public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : MessageBase; public record DalamudLogoutMessage : MessageBase;
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage; public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;

View File

@@ -10,7 +10,7 @@ using MdlFile = Penumbra.GameData.Files.MdlFile;
using MsLogger = Microsoft.Extensions.Logging.ILogger; using MsLogger = Microsoft.Extensions.Logging.ILogger;
namespace LightlessSync.Services.ModelDecimation; namespace LightlessSync.Services.ModelDecimation;
// if you're coming from another sync service, then kindly fuck off. lightless ftw lil bro
internal static class MdlDecimator internal static class MdlDecimator
{ {
private const int MaxStreams = 3; private const int MaxStreams = 3;
@@ -22,6 +22,7 @@ internal static class MdlDecimator
MdlFile.VertexUsage.Position, MdlFile.VertexUsage.Position,
MdlFile.VertexUsage.Normal, MdlFile.VertexUsage.Normal,
MdlFile.VertexUsage.Tangent1, MdlFile.VertexUsage.Tangent1,
MdlFile.VertexUsage.Tangent2,
MdlFile.VertexUsage.UV, MdlFile.VertexUsage.UV,
MdlFile.VertexUsage.Color, MdlFile.VertexUsage.Color,
MdlFile.VertexUsage.BlendWeights, MdlFile.VertexUsage.BlendWeights,
@@ -30,6 +31,7 @@ internal static class MdlDecimator
private static readonly HashSet<MdlFile.VertexType> SupportedTypes = private static readonly HashSet<MdlFile.VertexType> SupportedTypes =
[ [
MdlFile.VertexType.Single1,
MdlFile.VertexType.Single2, MdlFile.VertexType.Single2,
MdlFile.VertexType.Single3, MdlFile.VertexType.Single3,
MdlFile.VertexType.Single4, MdlFile.VertexType.Single4,
@@ -37,9 +39,15 @@ internal static class MdlDecimator
MdlFile.VertexType.Half4, MdlFile.VertexType.Half4,
MdlFile.VertexType.UByte4, MdlFile.VertexType.UByte4,
MdlFile.VertexType.NByte4, 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 try
{ {
@@ -116,7 +124,7 @@ internal static class MdlDecimator
bool decimated; bool decimated;
if (meshIndex >= lodMeshStart && meshIndex < lodMeshEnd 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 updatedMesh,
out updatedSubMeshes, out updatedSubMeshes,
out vertexStreams, out vertexStreams,
@@ -309,6 +317,7 @@ internal static class MdlDecimator
MdlStructs.SubmeshStruct[] meshSubMeshes, MdlStructs.SubmeshStruct[] meshSubMeshes,
int triangleThreshold, int triangleThreshold,
double targetRatio, double targetRatio,
bool normalizeTangents,
out MeshStruct updatedMesh, out MeshStruct updatedMesh,
out MdlStructs.SubmeshStruct[] updatedSubMeshes, out MdlStructs.SubmeshStruct[] updatedSubMeshes,
out byte[][] vertexStreams, out byte[][] vertexStreams,
@@ -370,7 +379,7 @@ internal static class MdlDecimator
return false; 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); logger.LogDebug("Mesh {MeshIndex} encode failed: {Reason}", meshIndex, encodeReason);
return false; return false;
@@ -405,11 +414,26 @@ internal static class MdlDecimator
mesh.Normals = decoded.Normals; mesh.Normals = decoded.Normals;
} }
if (decoded.PositionWs != null)
{
mesh.PositionWs = decoded.PositionWs;
}
if (decoded.NormalWs != null)
{
mesh.NormalWs = decoded.NormalWs;
}
if (decoded.Tangents != null) if (decoded.Tangents != null)
{ {
mesh.Tangents = decoded.Tangents; mesh.Tangents = decoded.Tangents;
} }
if (decoded.Tangents2 != null)
{
mesh.Tangents2 = decoded.Tangents2;
}
if (decoded.Colors != null) if (decoded.Colors != null)
{ {
mesh.Colors = decoded.Colors; mesh.Colors = decoded.Colors;
@@ -453,9 +477,12 @@ internal static class MdlDecimator
var vertexCount = mesh.VertexCount; var vertexCount = mesh.VertexCount;
var positions = new Vector3d[vertexCount]; var positions = new Vector3d[vertexCount];
Vector3[]? normals = format.HasNormals ? new Vector3[vertexCount] : null; 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; Vector4[]? colors = format.HasColors ? new Vector4[vertexCount] : null;
BoneWeight[]? boneWeights = format.HasSkinning ? new BoneWeight[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; Vector2[][]? uvChannels = null;
if (format.UvChannelCount > 0) 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); var uvLookup = format.UvElements.ToDictionary(static element => ElementKey.From(element.Element), static element => element);
for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++) for (var vertexIndex = 0; vertexIndex < vertexCount; vertexIndex++)
{ {
byte[]? indices = null; int[]? indices = null;
float[]? weights = null; float[]? weights = null;
foreach (var element in format.SortedElements) foreach (var element in format.SortedElements)
@@ -489,14 +516,31 @@ internal static class MdlDecimator
switch (usage) switch (usage)
{ {
case MdlFile.VertexUsage.Position: case MdlFile.VertexUsage.Position:
if (type == MdlFile.VertexType.Single4 && positionWs != null)
{
positions[vertexIndex] = ReadPositionWithW(stream, out positionWs[vertexIndex]);
}
else
{
positions[vertexIndex] = ReadPosition(type, stream); positions[vertexIndex] = ReadPosition(type, stream);
}
break; break;
case MdlFile.VertexUsage.Normal when normals != null: 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); normals[vertexIndex] = ReadNormal(type, stream);
}
break; break;
case MdlFile.VertexUsage.Tangent1 when tangents != null: case MdlFile.VertexUsage.Tangent1 when tangents != null:
tangents[vertexIndex] = ReadTangent(type, stream); tangents[vertexIndex] = ReadTangent(type, stream);
break; break;
case MdlFile.VertexUsage.Tangent2 when tangents2 != null:
tangents2[vertexIndex] = ReadTangent(type, stream);
break;
case MdlFile.VertexUsage.Color when colors != null: case MdlFile.VertexUsage.Color when colors != null:
colors[vertexIndex] = ReadColor(type, stream); colors[vertexIndex] = ReadColor(type, stream);
break; break;
@@ -516,6 +560,7 @@ internal static class MdlDecimator
break; break;
default: default:
if (usage == MdlFile.VertexUsage.Normal || usage == MdlFile.VertexUsage.Tangent1 if (usage == MdlFile.VertexUsage.Normal || usage == MdlFile.VertexUsage.Tangent1
|| usage == MdlFile.VertexUsage.Tangent2
|| usage == MdlFile.VertexUsage.Color) || usage == MdlFile.VertexUsage.Color)
{ {
_ = ReadAndDiscard(type, stream); _ = 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; return true;
} }
@@ -546,6 +591,7 @@ internal static class MdlDecimator
VertexFormat format, VertexFormat format,
MeshStruct originalMesh, MeshStruct originalMesh,
MdlStructs.SubmeshStruct[] originalSubMeshes, MdlStructs.SubmeshStruct[] originalSubMeshes,
bool normalizeTangents,
out MeshStruct updatedMesh, out MeshStruct updatedMesh,
out MdlStructs.SubmeshStruct[] updatedSubMeshes, out MdlStructs.SubmeshStruct[] updatedSubMeshes,
out byte[][] vertexStreams, out byte[][] vertexStreams,
@@ -567,8 +613,11 @@ internal static class MdlDecimator
var normals = decimatedMesh.Normals; var normals = decimatedMesh.Normals;
var tangents = decimatedMesh.Tangents; var tangents = decimatedMesh.Tangents;
var tangents2 = decimatedMesh.Tangents2;
var colors = decimatedMesh.Colors; var colors = decimatedMesh.Colors;
var boneWeights = decimatedMesh.BoneWeights; var boneWeights = decimatedMesh.BoneWeights;
var positionWs = decimatedMesh.PositionWs;
var normalWs = decimatedMesh.NormalWs;
if (format.HasNormals && normals == null) if (format.HasNormals && normals == null)
{ {
@@ -576,12 +625,24 @@ internal static class MdlDecimator
return false; return false;
} }
if (format.HasTangents && tangents == null) if (format.HasTangent1 && tangents == null)
{ {
reason = "Missing tangents after decimation."; reason = "Missing tangent1 after decimation.";
return false; 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) if (format.HasColors && colors == null)
{ {
reason = "Missing colors after decimation."; reason = "Missing colors after decimation.";
@@ -594,6 +655,18 @@ internal static class MdlDecimator
return false; 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[]>(); var uvChannels = Array.Empty<Vector2[]>();
if (format.UvChannelCount > 0) if (format.UvChannelCount > 0)
{ {
@@ -659,14 +732,17 @@ internal static class MdlDecimator
switch (usage) switch (usage)
{ {
case MdlFile.VertexUsage.Position: case MdlFile.VertexUsage.Position:
WritePosition(type, decimatedMesh.Vertices[vertexIndex], target); WritePosition(type, decimatedMesh.Vertices[vertexIndex], target, positionWs != null ? positionWs[vertexIndex] : null);
break; break;
case MdlFile.VertexUsage.Normal when normals != null: case MdlFile.VertexUsage.Normal when normals != null:
WriteNormal(type, normals[vertexIndex], target); WriteNormal(type, normals[vertexIndex], target, normalWs != null ? normalWs[vertexIndex] : null);
break; break;
case MdlFile.VertexUsage.Tangent1 when tangents != null: case MdlFile.VertexUsage.Tangent1 when tangents != null:
WriteTangent(type, tangents[vertexIndex], target); WriteTangent(type, tangents[vertexIndex], target);
break; break;
case MdlFile.VertexUsage.Tangent2 when tangents2 != null:
WriteTangent(type, tangents2[vertexIndex], target);
break;
case MdlFile.VertexUsage.Color when colors != null: case MdlFile.VertexUsage.Color when colors != null:
WriteColor(type, colors[vertexIndex], target); WriteColor(type, colors[vertexIndex], target);
break; break;
@@ -876,26 +952,50 @@ internal static class MdlDecimator
if (normalElements.Length == 1) if (normalElements.Length == 1)
{ {
var normalType = (MdlFile.VertexType)normalElements[0].Type; 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."; reason = "Unsupported normal element type.";
return false; return false;
} }
} }
var tangentElements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray(); var tangent1Elements = elements.Where(static e => (MdlFile.VertexUsage)e.Usage == MdlFile.VertexUsage.Tangent1).ToArray();
if (tangentElements.Length > 1) if (tangent1Elements.Length > 1)
{ {
reason = "Multiple tangent elements unsupported."; reason = "Multiple tangent1 elements unsupported.";
return false; return false;
} }
if (tangentElements.Length == 1) if (tangent1Elements.Length == 1)
{ {
var tangentType = (MdlFile.VertexType)tangentElements[0].Type; var tangentType = (MdlFile.VertexType)tangent1Elements[0].Type;
if (tangentType != MdlFile.VertexType.Single4 && tangentType != MdlFile.VertexType.NByte4) 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; return false;
} }
} }
@@ -911,7 +1011,12 @@ internal static class MdlDecimator
if (colorElements.Length == 1) if (colorElements.Length == 1)
{ {
var colorType = (MdlFile.VertexType)colorElements[0].Type; 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."; reason = "Unsupported color element type.";
return false; return false;
@@ -937,14 +1042,18 @@ internal static class MdlDecimator
if (blendIndicesElements.Length == 1) if (blendIndicesElements.Length == 1)
{ {
var indexType = (MdlFile.VertexType)blendIndicesElements[0].Type; 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."; reason = "Unsupported blend index type.";
return false; return false;
} }
var weightType = (MdlFile.VertexType)blendWeightsElements[0].Type; 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."; reason = "Unsupported blend weight type.";
return false; return false;
@@ -956,11 +1065,14 @@ internal static class MdlDecimator
return false; return false;
} }
var positionElement = positionElements[0];
var sortedElements = elements.OrderBy(static element => element.Offset).ToList(); var sortedElements = elements.OrderBy(static element => element.Offset).ToList();
format = new VertexFormat( format = new VertexFormat(
sortedElements, sortedElements,
positionElement,
normalElements.Length == 1 ? normalElements[0] : (MdlStructs.VertexElement?)null, 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, colorElement,
blendIndicesElements.Length == 1 ? blendIndicesElements[0] : (MdlStructs.VertexElement?)null, blendIndicesElements.Length == 1 ? blendIndicesElements[0] : (MdlStructs.VertexElement?)null,
blendWeightsElements.Length == 1 ? blendWeightsElements[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) foreach (var element in uvList)
{ {
var type = (MdlFile.VertexType)element.Type; 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) if (uvChannelCount + 1 > Mesh.UVChannelCount)
{ {
@@ -998,7 +1115,11 @@ internal static class MdlDecimator
uvElements.Add(new UvElementPacking(element, uvChannelCount, null)); uvElements.Add(new UvElementPacking(element, uvChannelCount, null));
uvChannelCount += 1; 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) 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) private static Vector3 ReadNormal(MdlFile.VertexType type, BinaryReader reader)
{ {
switch (type) switch (type)
@@ -1056,17 +1186,29 @@ internal static class MdlDecimator
return new Vector3(x, y, z); return new Vector3(x, y, z);
case MdlFile.VertexType.NByte4: case MdlFile.VertexType.NByte4:
return ReadNByte4(reader).ToVector3(); return ReadNByte4(reader).ToVector3();
case MdlFile.VertexType.NShort4:
return ReadNShort4(reader).ToVector3();
default: default:
throw new InvalidOperationException($"Unsupported normal type {type}"); 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) private static Vector4 ReadTangent(MdlFile.VertexType type, BinaryReader reader)
{ {
return type switch return type switch
{ {
MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()),
MdlFile.VertexType.NByte4 => ReadNByte4(reader), MdlFile.VertexType.NByte4 => ReadNByte4(reader),
MdlFile.VertexType.NShort4 => ReadNShort4(reader),
_ => throw new InvalidOperationException($"Unsupported tangent type {type}"), _ => throw new InvalidOperationException($"Unsupported tangent type {type}"),
}; };
} }
@@ -1078,27 +1220,79 @@ internal static class MdlDecimator
MdlFile.VertexType.UByte4 => ReadUByte4(reader), MdlFile.VertexType.UByte4 => ReadUByte4(reader),
MdlFile.VertexType.NByte4 => ReadUByte4(reader), MdlFile.VertexType.NByte4 => ReadUByte4(reader),
MdlFile.VertexType.Single4 => new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), 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}"), _ => 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) 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 var uv = type switch
? new Vector2(ReadHalf(reader), ReadHalf(reader)) {
: new Vector2(reader.ReadSingle(), reader.ReadSingle()); 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; uvChannels[mapping.FirstChannel][vertexIndex] = uv;
return; 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 var uv = type switch
? new Vector4(ReadHalf(reader), ReadHalf(reader), ReadHalf(reader), ReadHalf(reader)) {
: new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); 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); uvChannels[mapping.FirstChannel][vertexIndex] = new Vector2(uv.x, uv.y);
if (mapping.SecondChannel.HasValue) 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 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}"), _ => throw new InvalidOperationException($"Unsupported indices type {type}"),
}; };
} }
@@ -1124,6 +1319,8 @@ internal static class MdlDecimator
MdlFile.VertexType.UByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.UByte4 => ReadUByte4(reader).ToFloatArray(),
MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(), MdlFile.VertexType.NByte4 => ReadUByte4(reader).ToFloatArray(),
MdlFile.VertexType.Single4 => new[] { reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle() }, 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}"), _ => throw new InvalidOperationException($"Unsupported weights type {type}"),
}; };
} }
@@ -1143,29 +1340,98 @@ internal static class MdlDecimator
return (value * 2f) - new Vector4(1f, 1f, 1f, 1f); 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 var value = ReadUShort4Normalized(reader);
{ return (value * 2f) - new Vector4(1f, 1f, 1f, 1f);
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,
};
} }
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); 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) private static void WriteTangent(MdlFile.VertexType type, Vector4 value, Span<byte> target)
@@ -1176,12 +1442,21 @@ internal static class MdlDecimator
return; return;
} }
if (type == MdlFile.VertexType.NShort4)
{
WriteNShort4(value, target);
return;
}
WriteVector4(type, value, target); WriteVector4(type, value, target);
} }
private static void WriteColor(MdlFile.VertexType type, Vector4 value, Span<byte> 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); WriteVector4(type, value, target);
return; return;
@@ -1192,20 +1467,25 @@ internal static class MdlDecimator
private static void WriteBlendIndices(MdlFile.VertexType type, BoneWeight weights, Span<byte> target) 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[0] = (byte)Math.Clamp(weights.boneIndex0, 0, 255);
target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255); target[1] = (byte)Math.Clamp(weights.boneIndex1, 0, 255);
target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255); target[2] = (byte)Math.Clamp(weights.boneIndex2, 0, 255);
target[3] = (byte)Math.Clamp(weights.boneIndex3, 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) 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) 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(4, 4), weights.boneWeight1);
BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2); BinaryPrimitives.WriteSingleLittleEndian(target.Slice(8, 4), weights.boneWeight2);
BinaryPrimitives.WriteSingleLittleEndian(target.Slice(12, 4), weights.boneWeight3); 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; return;
} }
@@ -1223,6 +1510,15 @@ internal static class MdlDecimator
var w3 = Clamp01(weights.boneWeight3); var w3 = Clamp01(weights.boneWeight3);
NormalizeWeights(ref w0, ref w1, ref w2, ref w3); 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[0] = ToByte(w0);
target[1] = ToByte(w1); target[1] = ToByte(w1);
target[2] = ToByte(w2); 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) 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]; var uv = uvChannels[mapping.FirstChannel][vertexIndex];
WriteVector2(type, uv, target); WriteVector2(type, uv, target);
return; 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 uv0 = uvChannels[mapping.FirstChannel][vertexIndex];
var uv1 = mapping.SecondChannel.HasValue 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) 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) if (type == MdlFile.VertexType.Single2)
{ {
BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x); BinaryPrimitives.WriteSingleLittleEndian(target[..4], value.x);
@@ -1261,6 +1572,24 @@ internal static class MdlDecimator
{ {
WriteHalf(target[..2], value.x); WriteHalf(target[..2], value.x);
WriteHalf(target.Slice(2, 2), value.y); 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) if (type == MdlFile.VertexType.NByte4 && normalized)
{ {
WriteNByte4(new Vector4(value.x, value.y, value.z, 0f), target); 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); WriteHalf(target.Slice(6, 2), value.w);
return; 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) private static void WriteUByte4(Vector4 value, Span<byte> target)
@@ -1324,6 +1676,58 @@ internal static class MdlDecimator
WriteUByte4(normalized, target); 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) private static void WriteHalf(Span<byte> target, float value)
{ {
var half = (Half)value; var half = (Half)value;
@@ -1336,9 +1740,32 @@ internal static class MdlDecimator
private static float Clamp01(float value) private static float Clamp01(float value)
=> Math.Clamp(value, 0f, 1f); => Math.Clamp(value, 0f, 1f);
private static float ClampMinusOneToOne(float value)
=> Math.Clamp(value, -1f, 1f);
private static byte ToByte(float value) private static byte ToByte(float value)
=> (byte)Math.Clamp((int)Math.Round(value * 255f), 0, 255); => (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) private static void NormalizeWeights(float[] weights)
{ {
var sum = weights.Sum(); var sum = weights.Sum();
@@ -1370,6 +1797,7 @@ internal static class MdlDecimator
private static int GetElementSize(MdlFile.VertexType type) private static int GetElementSize(MdlFile.VertexType type)
=> type switch => type switch
{ {
MdlFile.VertexType.Single1 => 4,
MdlFile.VertexType.Single2 => 8, MdlFile.VertexType.Single2 => 8,
MdlFile.VertexType.Single3 => 12, MdlFile.VertexType.Single3 => 12,
MdlFile.VertexType.Single4 => 16, MdlFile.VertexType.Single4 => 16,
@@ -1377,6 +1805,12 @@ internal static class MdlDecimator
MdlFile.VertexType.Half4 => 8, MdlFile.VertexType.Half4 => 8,
MdlFile.VertexType.UByte4 => 4, MdlFile.VertexType.UByte4 => 4,
MdlFile.VertexType.NByte4 => 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}"), _ => throw new InvalidOperationException($"Unsupported vertex type {type}"),
}; };
@@ -1390,8 +1824,10 @@ internal static class MdlDecimator
{ {
public VertexFormat( public VertexFormat(
List<MdlStructs.VertexElement> sortedElements, List<MdlStructs.VertexElement> sortedElements,
MdlStructs.VertexElement positionElement,
MdlStructs.VertexElement? normalElement, MdlStructs.VertexElement? normalElement,
MdlStructs.VertexElement? tangentElement, MdlStructs.VertexElement? tangent1Element,
MdlStructs.VertexElement? tangent2Element,
MdlStructs.VertexElement? colorElement, MdlStructs.VertexElement? colorElement,
MdlStructs.VertexElement? blendIndicesElement, MdlStructs.VertexElement? blendIndicesElement,
MdlStructs.VertexElement? blendWeightsElement, MdlStructs.VertexElement? blendWeightsElement,
@@ -1399,8 +1835,10 @@ internal static class MdlDecimator
int uvChannelCount) int uvChannelCount)
{ {
SortedElements = sortedElements; SortedElements = sortedElements;
PositionElement = positionElement;
NormalElement = normalElement; NormalElement = normalElement;
TangentElement = tangentElement; Tangent1Element = tangent1Element;
Tangent2Element = tangent2Element;
ColorElement = colorElement; ColorElement = colorElement;
BlendIndicesElement = blendIndicesElement; BlendIndicesElement = blendIndicesElement;
BlendWeightsElement = blendWeightsElement; BlendWeightsElement = blendWeightsElement;
@@ -1409,8 +1847,10 @@ internal static class MdlDecimator
} }
public List<MdlStructs.VertexElement> SortedElements { get; } public List<MdlStructs.VertexElement> SortedElements { get; }
public MdlStructs.VertexElement PositionElement { get; }
public MdlStructs.VertexElement? NormalElement { 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? ColorElement { get; }
public MdlStructs.VertexElement? BlendIndicesElement { get; } public MdlStructs.VertexElement? BlendIndicesElement { get; }
public MdlStructs.VertexElement? BlendWeightsElement { get; } public MdlStructs.VertexElement? BlendWeightsElement { get; }
@@ -1418,9 +1858,12 @@ internal static class MdlDecimator
public int UvChannelCount { get; } public int UvChannelCount { get; }
public bool HasNormals => NormalElement.HasValue; 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 HasColors => ColorElement.HasValue;
public bool HasSkinning => BlendIndicesElement.HasValue && BlendWeightsElement.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); private readonly record struct UvElementPacking(MdlStructs.VertexElement Element, int FirstChannel, int? SecondChannel);
@@ -1431,24 +1874,33 @@ internal static class MdlDecimator
Vector3d[] positions, Vector3d[] positions,
Vector3[]? normals, Vector3[]? normals,
Vector4[]? tangents, Vector4[]? tangents,
Vector4[]? tangents2,
Vector4[]? colors, Vector4[]? colors,
BoneWeight[]? boneWeights, BoneWeight[]? boneWeights,
Vector2[][]? uvChannels) Vector2[][]? uvChannels,
float[]? positionWs,
float[]? normalWs)
{ {
Positions = positions; Positions = positions;
Normals = normals; Normals = normals;
Tangents = tangents; Tangents = tangents;
Tangents2 = tangents2;
Colors = colors; Colors = colors;
BoneWeights = boneWeights; BoneWeights = boneWeights;
UvChannels = uvChannels; UvChannels = uvChannels;
PositionWs = positionWs;
NormalWs = normalWs;
} }
public Vector3d[] Positions { get; } public Vector3d[] Positions { get; }
public Vector3[]? Normals { get; } public Vector3[]? Normals { get; }
public Vector4[]? Tangents { get; } public Vector4[]? Tangents { get; }
public Vector4[]? Tangents2 { get; }
public Vector4[]? Colors { get; } public Vector4[]? Colors { get; }
public BoneWeight[]? BoneWeights { get; } public BoneWeight[]? BoneWeights { get; }
public Vector2[][]? UvChannels { get; } public Vector2[][]? UvChannels { get; }
public float[]? PositionWs { get; }
public float[]? NormalWs { get; }
} }
} }

View File

@@ -1,5 +1,6 @@
using LightlessSync.FileCache; using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
@@ -19,7 +20,7 @@ public sealed class ModelDecimationService
private readonly XivDataStorageService _xivDataStorageService; private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs); 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, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
@@ -44,14 +45,14 @@ public sealed class ModelDecimationService
return; return;
} }
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash)) if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _))
{ {
return; return;
} }
_logger.LogInformation("Queued model decimation for {Hash}", hash); _logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () => _decimationDeduplicator.GetOrStart(hash, async () =>
{ {
await _decimationSemaphore.WaitAsync().ConfigureAwait(false); await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try try
@@ -66,9 +67,8 @@ public sealed class ModelDecimationService
finally finally
{ {
_decimationSemaphore.Release(); _decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
} }
}, CancellationToken.None); });
} }
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null) public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
@@ -116,7 +116,7 @@ public sealed class ModelDecimationService
continue; continue;
} }
if (_activeJobs.TryGetValue(hash, out var job)) if (_decimationDeduplicator.TryGetExisting(hash, out var job))
{ {
pending.Add(job); pending.Add(job);
} }
@@ -139,13 +139,18 @@ public sealed class ModelDecimationService
return Task.CompletedTask; 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); _logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash);
return Task.CompletedTask; 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"); var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination)) if (File.Exists(destination))
@@ -154,7 +159,7 @@ public sealed class ModelDecimationService
return Task.CompletedTask; return Task.CompletedTask;
} }
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger)) if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, normalizeTangents, _logger))
{ {
_failedHashes[hash] = 1; _failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash); _logger.LogInformation("Model decimation skipped for {Hash}", hash);
@@ -313,10 +318,11 @@ public sealed class ModelDecimationService
private static string NormalizeGamePath(string path) private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant(); => 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; triangleThreshold = 15_000;
targetRatio = 0.8; targetRatio = 0.8;
normalizeTangents = true;
var config = _performanceConfigService.Current; var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation) if (!config.EnableModelDecimation)
@@ -326,6 +332,7 @@ public sealed class ModelDecimationService
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold); triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio; targetRatio = config.ModelDecimationTargetRatio;
normalizeTangents = config.ModelDecimationNormalizeTangents;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio)) if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{ {
return false; return false;

View File

@@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using System.Globalization;
namespace LightlessSync.Services.TextureCompression; namespace LightlessSync.Services.TextureCompression;
@@ -27,7 +28,9 @@ public sealed class TextureCompressionService
public async Task ConvertTexturesAsync( public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests, IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress, IProgress<TextureConversionProgress>? progress,
CancellationToken token) CancellationToken token,
bool requestRedraw = true,
bool includeMipMaps = true)
{ {
if (requests.Count == 0) if (requests.Count == 0)
{ {
@@ -48,7 +51,7 @@ public sealed class TextureCompressionService
continue; continue;
} }
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false); await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false);
completed++; completed++;
} }
@@ -65,14 +68,16 @@ public sealed class TextureCompressionService
int total, int total,
int completedBefore, int completedBefore,
IProgress<TextureConversionProgress>? progress, IProgress<TextureConversionProgress>? progress,
CancellationToken token) CancellationToken token,
bool requestRedraw,
bool includeMipMaps)
{ {
var primaryPath = request.PrimaryFilePath; var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob( var displayJob = new TextureConversionJob(
primaryPath, primaryPath,
primaryPath, primaryPath,
targetType, targetType,
IncludeMipMaps: true, IncludeMipMaps: includeMipMaps,
request.DuplicateFilePaths); request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath); var backupPath = CreateBackupCopy(primaryPath);
@@ -83,7 +88,7 @@ public sealed class TextureCompressionService
try try
{ {
WaitForAccess(primaryPath); 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)) if (!IsValidConversionResult(displayJob.OutputFile))
{ {
@@ -128,20 +133,47 @@ public sealed class TextureCompressionService
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray()); var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths) foreach (var path in paths)
{ {
var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash);
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null) 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) if (entry is null)
{ {
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path); _logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue; 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 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); _fileCacheManager.UpdateHashedFile(entry);
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path); _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 = private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression"); Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");

View File

@@ -4,9 +4,11 @@ using System.Buffers.Binary;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading;
using OtterTex; using OtterTex;
using OtterImage = OtterTex.Image; using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration;
using LightlessSync.Utils;
using LightlessSync.FileCache; using LightlessSync.FileCache;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Lumina.Data.Files; using Lumina.Data.Files;
@@ -30,10 +32,12 @@ public sealed class TextureDownscaleService
private readonly LightlessConfigService _configService; private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager; 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 ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4); private readonly SemaphoreSlim _downscaleSemaphore = new(4);
private readonly SemaphoreSlim _compressionSemaphore = new(1);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap = private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget> new Dictionary<int, TextureCompressionTarget>
{ {
@@ -68,12 +72,14 @@ public sealed class TextureDownscaleService
ILogger<TextureDownscaleService> logger, ILogger<TextureDownscaleService> logger,
LightlessConfigService configService, LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService, PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager) FileCacheManager fileCacheManager,
TextureCompressionService textureCompressionService)
{ {
_logger = logger; _logger = logger;
_configService = configService; _configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService; _playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager; _fileCacheManager = fileCacheManager;
_textureCompressionService = textureCompressionService;
} }
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) 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) public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{ {
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; 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; TextureMapKind mapKind;
try try
@@ -98,7 +104,7 @@ public sealed class TextureDownscaleService
} }
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None); });
} }
public bool ShouldScheduleDownscale(string filePath) public bool ShouldScheduleDownscale(string filePath)
@@ -107,7 +113,9 @@ public sealed class TextureDownscaleService
return false; return false;
var performanceConfig = _playerPerformanceConfigService.Current; var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale; return performanceConfig.EnableNonIndexTextureMipTrim
|| performanceConfig.EnableIndexTextureDownscale
|| performanceConfig.EnableUncompressedTextureCompression;
} }
public string GetPreferredPath(string hash, string originalPath) public string GetPreferredPath(string hash, string originalPath)
@@ -144,7 +152,7 @@ public sealed class TextureDownscaleService
continue; continue;
} }
if (_activeJobs.TryGetValue(hash, out var job)) if (_downscaleDeduplicator.TryGetExisting(hash, out var job))
{ {
pending.Add(job); pending.Add(job);
} }
@@ -182,10 +190,18 @@ public sealed class TextureDownscaleService
targetMaxDimension = ResolveTargetMaxDimension(); targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures; 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"); destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination)) if (File.Exists(destination))
{ {
RegisterDownscaledTexture(hash, sourcePath, destination); RegisterDownscaledTexture(hash, sourcePath, destination);
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return; return;
} }
@@ -196,6 +212,7 @@ public sealed class TextureDownscaleService
if (performanceConfig.EnableNonIndexTextureMipTrim if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false)) && await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{ {
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return; return;
} }
@@ -206,6 +223,7 @@ public sealed class TextureDownscaleService
_downscaledPaths[hash] = sourcePath; _downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash); _logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return; return;
} }
@@ -213,6 +231,7 @@ public sealed class TextureDownscaleService
{ {
_downscaledPaths[hash] = sourcePath; _downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash); _logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return; return;
} }
@@ -222,6 +241,7 @@ public sealed class TextureDownscaleService
{ {
_downscaledPaths[hash] = sourcePath; _downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height); _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; return;
} }
@@ -229,10 +249,12 @@ public sealed class TextureDownscaleService
{ {
_downscaledPaths[hash] = sourcePath; _downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format); _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; return;
} }
using var sourceScratch = TexFileHelper.Load(sourcePath); using var sourceScratch = TexFileHelper.Load(sourcePath);
var sourceFormat = sourceScratch.Meta.Format;
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo); using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8; var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
@@ -248,17 +270,40 @@ public sealed class TextureDownscaleService
{ {
_downscaledPaths[hash] = sourcePath; _downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash); _logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return; return;
} }
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple); 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 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); TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination); RegisterDownscaledTexture(hash, sourcePath, destination);
} }
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
}
catch (Exception ex) catch (Exception ex)
{ {
TryDelete(destination); TryDelete(destination);
@@ -277,7 +322,6 @@ public sealed class TextureDownscaleService
finally finally
{ {
_downscaleSemaphore.Release(); _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) private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask => kind is TextureMapKind.Mask
or TextureMapKind.Index; or TextureMapKind.Index;

View File

@@ -13,16 +13,20 @@ namespace LightlessSync.Services;
public sealed class UiService : DisposableMediatorSubscriberBase public sealed class UiService : DisposableMediatorSubscriberBase
{ {
private readonly List<WindowMediatorSubscriberBase> _createdWindows = []; private readonly List<WindowMediatorSubscriberBase> _createdWindows = [];
private readonly List<WindowMediatorSubscriberBase> _registeredWindows = [];
private readonly HashSet<WindowMediatorSubscriberBase> _uiHiddenWindows = [];
private readonly IUiBuilder _uiBuilder; private readonly IUiBuilder _uiBuilder;
private readonly FileDialogManager _fileDialogManager; private readonly FileDialogManager _fileDialogManager;
private readonly ILogger<UiService> _logger; private readonly ILogger<UiService> _logger;
private readonly LightlessConfigService _lightlessConfigService; private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly WindowSystem _windowSystem; private readonly WindowSystem _windowSystem;
private readonly UiFactory _uiFactory; private readonly UiFactory _uiFactory;
private readonly PairFactory _pairFactory; private readonly PairFactory _pairFactory;
private bool _uiHideActive;
public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder, public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder,
LightlessConfigService lightlessConfigService, WindowSystem windowSystem, LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem,
IEnumerable<WindowMediatorSubscriberBase> windows, IEnumerable<WindowMediatorSubscriberBase> windows,
UiFactory uiFactory, FileDialogManager fileDialogManager, UiFactory uiFactory, FileDialogManager fileDialogManager,
LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator) LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator)
@@ -31,6 +35,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
_logger.LogTrace("Creating {type}", GetType().Name); _logger.LogTrace("Creating {type}", GetType().Name);
_uiBuilder = uiBuilder; _uiBuilder = uiBuilder;
_lightlessConfigService = lightlessConfigService; _lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
_windowSystem = windowSystem; _windowSystem = windowSystem;
_uiFactory = uiFactory; _uiFactory = uiFactory;
_pairFactory = pairFactory; _pairFactory = pairFactory;
@@ -43,6 +48,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
foreach (var window in windows) foreach (var window in windows)
{ {
_registeredWindows.Add(window);
_windowSystem.AddWindow(window); _windowSystem.AddWindow(window);
} }
@@ -176,6 +182,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase
{ {
_windowSystem.RemoveWindow(msg.Window); _windowSystem.RemoveWindow(msg.Window);
_createdWindows.Remove(msg.Window); _createdWindows.Remove(msg.Window);
_registeredWindows.Remove(msg.Window);
_uiHiddenWindows.Remove(msg.Window);
msg.Window.Dispose(); msg.Window.Dispose();
}); });
} }
@@ -219,7 +227,10 @@ public sealed class UiService : DisposableMediatorSubscriberBase
MainStyle.PushStyle(); MainStyle.PushStyle();
try try
{ {
var hideOtherUi = ShouldHideOtherUi();
UpdateUiHideState(hideOtherUi);
_windowSystem.Draw(); _windowSystem.Draw();
if (!hideOtherUi)
_fileDialogManager.Draw(); _fileDialogManager.Draw();
} }
finally finally
@@ -227,4 +238,61 @@ public sealed class UiService : DisposableMediatorSubscriberBase
MainStyle.PopStyle(); 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;
}
}
} }

View File

@@ -239,11 +239,14 @@ namespace MeshDecimator.Algorithms
private ResizableArray<Vector3> vertNormals = null; private ResizableArray<Vector3> vertNormals = null;
private ResizableArray<Vector4> vertTangents = null; private ResizableArray<Vector4> vertTangents = null;
private ResizableArray<Vector4> vertTangents2 = null;
private UVChannels<Vector2> vertUV2D = null; private UVChannels<Vector2> vertUV2D = null;
private UVChannels<Vector3> vertUV3D = null; private UVChannels<Vector3> vertUV3D = null;
private UVChannels<Vector4> vertUV4D = null; private UVChannels<Vector4> vertUV4D = null;
private ResizableArray<Vector4> vertColors = null; private ResizableArray<Vector4> vertColors = null;
private ResizableArray<BoneWeight> vertBoneWeights = null; private ResizableArray<BoneWeight> vertBoneWeights = null;
private ResizableArray<float> vertPositionWs = null;
private ResizableArray<float> vertNormalWs = null;
private int remainingVertices = 0; private int remainingVertices = 0;
@@ -508,10 +511,22 @@ namespace MeshDecimator.Algorithms
{ {
vertNormals[i0] = vertNormals[i1]; vertNormals[i0] = vertNormals[i1];
} }
if (vertPositionWs != null)
{
vertPositionWs[i0] = vertPositionWs[i1];
}
if (vertNormalWs != null)
{
vertNormalWs[i0] = vertNormalWs[i1];
}
if (vertTangents != null) if (vertTangents != null)
{ {
vertTangents[i0] = vertTangents[i1]; vertTangents[i0] = vertTangents[i1];
} }
if (vertTangents2 != null)
{
vertTangents2[i0] = vertTangents2[i1];
}
if (vertUV2D != null) if (vertUV2D != null)
{ {
for (int i = 0; i < Mesh.UVChannelCount; i++) for (int i = 0; i < Mesh.UVChannelCount; i++)
@@ -561,10 +576,22 @@ namespace MeshDecimator.Algorithms
{ {
vertNormals[i0] = (vertNormals[i0] + vertNormals[i1]) * 0.5f; 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) if (vertTangents != null)
{ {
vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f; vertTangents[i0] = (vertTangents[i0] + vertTangents[i1]) * 0.5f;
} }
if (vertTangents2 != null)
{
vertTangents2[i0] = (vertTangents2[i0] + vertTangents2[i1]) * 0.5f;
}
if (vertUV2D != null) if (vertUV2D != null)
{ {
for (int i = 0; i < Mesh.UVChannelCount; i++) 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 vertNormals = (this.vertNormals != null ? this.vertNormals.Data : null);
var vertTangents = (this.vertTangents != null ? this.vertTangents.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 vertUV2D = (this.vertUV2D != null ? this.vertUV2D.Data : null);
var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null); var vertUV3D = (this.vertUV3D != null ? this.vertUV3D.Data : null);
var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null); var vertUV4D = (this.vertUV4D != null ? this.vertUV4D.Data : null);
var vertColors = (this.vertColors != null ? this.vertColors.Data : null); var vertColors = (this.vertColors != null ? this.vertColors.Data : null);
var vertBoneWeights = (this.vertBoneWeights != null ? this.vertBoneWeights.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; var triangles = this.triangles.Data;
int triangleCount = this.triangles.Length; int triangleCount = this.triangles.Length;
@@ -1102,6 +1132,14 @@ namespace MeshDecimator.Algorithms
{ {
vertBoneWeights[iDest] = vertBoneWeights[iSrc]; vertBoneWeights[iDest] = vertBoneWeights[iSrc];
} }
if (vertPositionWs != null)
{
vertPositionWs[iDest] = vertPositionWs[iSrc];
}
if (vertNormalWs != null)
{
vertNormalWs[iDest] = vertNormalWs[iSrc];
}
triangle.v0 = triangle.va0; triangle.v0 = triangle.va0;
} }
if (triangle.va1 != triangle.v1) if (triangle.va1 != triangle.v1)
@@ -1113,6 +1151,14 @@ namespace MeshDecimator.Algorithms
{ {
vertBoneWeights[iDest] = vertBoneWeights[iSrc]; vertBoneWeights[iDest] = vertBoneWeights[iSrc];
} }
if (vertPositionWs != null)
{
vertPositionWs[iDest] = vertPositionWs[iSrc];
}
if (vertNormalWs != null)
{
vertNormalWs[iDest] = vertNormalWs[iSrc];
}
triangle.v1 = triangle.va1; triangle.v1 = triangle.va1;
} }
if (triangle.va2 != triangle.v2) if (triangle.va2 != triangle.v2)
@@ -1124,6 +1170,14 @@ namespace MeshDecimator.Algorithms
{ {
vertBoneWeights[iDest] = vertBoneWeights[iSrc]; vertBoneWeights[iDest] = vertBoneWeights[iSrc];
} }
if (vertPositionWs != null)
{
vertPositionWs[iDest] = vertPositionWs[iSrc];
}
if (vertNormalWs != null)
{
vertNormalWs[iDest] = vertNormalWs[iSrc];
}
triangle.v2 = triangle.va2; triangle.v2 = triangle.va2;
} }
@@ -1153,6 +1207,7 @@ namespace MeshDecimator.Algorithms
vertices[dst].p = vert.p; vertices[dst].p = vert.p;
if (vertNormals != null) vertNormals[dst] = vertNormals[i]; if (vertNormals != null) vertNormals[dst] = vertNormals[i];
if (vertTangents != null) vertTangents[dst] = vertTangents[i]; if (vertTangents != null) vertTangents[dst] = vertTangents[i];
if (vertTangents2 != null) vertTangents2[dst] = vertTangents2[i];
if (vertUV2D != null) if (vertUV2D != null)
{ {
for (int j = 0; j < Mesh.UVChannelCount; j++) for (int j = 0; j < Mesh.UVChannelCount; j++)
@@ -1188,6 +1243,8 @@ namespace MeshDecimator.Algorithms
} }
if (vertColors != null) vertColors[dst] = vertColors[i]; if (vertColors != null) vertColors[dst] = vertColors[i];
if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i]; if (vertBoneWeights != null) vertBoneWeights[dst] = vertBoneWeights[i];
if (vertPositionWs != null) vertPositionWs[dst] = vertPositionWs[i];
if (vertNormalWs != null) vertNormalWs[dst] = vertNormalWs[i];
} }
++dst; ++dst;
} }
@@ -1206,11 +1263,14 @@ namespace MeshDecimator.Algorithms
this.vertices.Resize(vertexCount); this.vertices.Resize(vertexCount);
if (vertNormals != null) this.vertNormals.Resize(vertexCount, true); if (vertNormals != null) this.vertNormals.Resize(vertexCount, true);
if (vertTangents != null) this.vertTangents.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 (vertUV2D != null) this.vertUV2D.Resize(vertexCount, true);
if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true); if (vertUV3D != null) this.vertUV3D.Resize(vertexCount, true);
if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true); if (vertUV4D != null) this.vertUV4D.Resize(vertexCount, true);
if (vertColors != null) this.vertColors.Resize(vertexCount, true); if (vertColors != null) this.vertColors.Resize(vertexCount, true);
if (vertBoneWeights != null) this.vertBoneWeights.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
#endregion #endregion
@@ -1230,7 +1290,10 @@ namespace MeshDecimator.Algorithms
int meshTriangleCount = mesh.TriangleCount; int meshTriangleCount = mesh.TriangleCount;
var meshVertices = mesh.Vertices; var meshVertices = mesh.Vertices;
var meshNormals = mesh.Normals; var meshNormals = mesh.Normals;
var meshPositionWs = mesh.PositionWs;
var meshNormalWs = mesh.NormalWs;
var meshTangents = mesh.Tangents; var meshTangents = mesh.Tangents;
var meshTangents2 = mesh.Tangents2;
var meshColors = mesh.Colors; var meshColors = mesh.Colors;
var meshBoneWeights = mesh.BoneWeights; var meshBoneWeights = mesh.BoneWeights;
subMeshCount = meshSubMeshCount; subMeshCount = meshSubMeshCount;
@@ -1260,7 +1323,10 @@ namespace MeshDecimator.Algorithms
} }
vertNormals = InitializeVertexAttribute(meshNormals, "normals"); vertNormals = InitializeVertexAttribute(meshNormals, "normals");
vertPositionWs = InitializeVertexAttribute(meshPositionWs, "positionWs");
vertNormalWs = InitializeVertexAttribute(meshNormalWs, "normalWs");
vertTangents = InitializeVertexAttribute(meshTangents, "tangents"); vertTangents = InitializeVertexAttribute(meshTangents, "tangents");
vertTangents2 = InitializeVertexAttribute(meshTangents2, "tangents2");
vertColors = InitializeVertexAttribute(meshColors, "colors"); vertColors = InitializeVertexAttribute(meshColors, "colors");
vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights"); vertBoneWeights = InitializeVertexAttribute(meshBoneWeights, "boneWeights");
@@ -1492,10 +1558,22 @@ namespace MeshDecimator.Algorithms
{ {
newMesh.Normals = vertNormals.Data; newMesh.Normals = vertNormals.Data;
} }
if (vertPositionWs != null)
{
newMesh.PositionWs = vertPositionWs.Data;
}
if (vertNormalWs != null)
{
newMesh.NormalWs = vertNormalWs.Data;
}
if (vertTangents != null) if (vertTangents != null)
{ {
newMesh.Tangents = vertTangents.Data; newMesh.Tangents = vertTangents.Data;
} }
if (vertTangents2 != null)
{
newMesh.Tangents2 = vertTangents2.Data;
}
if (vertColors != null) if (vertColors != null)
{ {
newMesh.Colors = vertColors.Data; newMesh.Colors = vertColors.Data;

View File

@@ -47,11 +47,14 @@ namespace MeshDecimator
private int[][] indices = null; private int[][] indices = null;
private Vector3[] normals = null; private Vector3[] normals = null;
private Vector4[] tangents = null; private Vector4[] tangents = null;
private Vector4[] tangents2 = null;
private Vector2[][] uvs2D = null; private Vector2[][] uvs2D = null;
private Vector3[][] uvs3D = null; private Vector3[][] uvs3D = null;
private Vector4[][] uvs4D = null; private Vector4[][] uvs4D = null;
private Vector4[] colors = null; private Vector4[] colors = null;
private BoneWeight[] boneWeights = null; private BoneWeight[] boneWeights = null;
private float[] positionWs = null;
private float[] normalWs = null;
private static readonly int[] emptyIndices = new int[0]; private static readonly int[] emptyIndices = new int[0];
#endregion #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> /// <summary>
/// Gets or sets the tangents for this mesh. /// Gets or sets the tangents for this mesh.
/// </summary> /// </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> /// <summary>
/// Gets or sets the first UV set for this mesh. /// Gets or sets the first UV set for this mesh.
/// </summary> /// </summary>
@@ -298,11 +346,14 @@ namespace MeshDecimator
{ {
normals = null; normals = null;
tangents = null; tangents = null;
tangents2 = null;
uvs2D = null; uvs2D = null;
uvs3D = null; uvs3D = null;
uvs4D = null; uvs4D = null;
colors = null; colors = null;
boneWeights = null; boneWeights = null;
positionWs = null;
normalWs = null;
} }
#endregion #endregion

View File

@@ -28,7 +28,6 @@ using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Numerics; using System.Numerics;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
namespace LightlessSync.UI; namespace LightlessSync.UI;
@@ -71,6 +70,7 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi; private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new(); private readonly SeluneBrush _seluneBrush = new();
private readonly TopTabMenu _tabMenu; private readonly TopTabMenu _tabMenu;
private readonly OptimizationSummaryCard _optimizationSummaryCard;
#endregion #endregion
@@ -86,7 +86,8 @@ public class CompactUi : WindowMediatorSubscriberBase
private int _pendingFocusFrame = -1; private int _pendingFocusFrame = -1;
private Pair? _pendingFocusPair; private Pair? _pendingFocusPair;
private bool _showModalForUserAddition; private bool _showModalForUserAddition;
private float _transferPartHeight; private float _footerPartHeight;
private bool _hasFooterPartHeight;
private bool _wasOpen; private bool _wasOpen;
private float _windowContentWidth; private float _windowContentWidth;
@@ -177,6 +178,7 @@ public class CompactUi : WindowMediatorSubscriberBase
_characterAnalyzer = characterAnalyzer; _characterAnalyzer = characterAnalyzer;
_playerPerformanceConfig = playerPerformanceConfig; _playerPerformanceConfig = playerPerformanceConfig;
_lightlessMediator = mediator; _lightlessMediator = mediator;
_optimizationSummaryCard = new OptimizationSummaryCard(_uiSharedService, _pairUiService, _playerPerformanceConfig, _fileTransferManager, _lightlessMediator);
} }
#endregion #endregion
@@ -262,12 +264,17 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot); using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs(); using (ImRaii.PushId("pairlist")) DrawPairs();
var transfersTop = ImGui.GetCursorScreenPos().Y; var footerTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset); var gradientBottom = MathF.Max(gradientTop, footerTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime); selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
float pairlistEnd = ImGui.GetCursorPosY(); float pairlistEnd = ImGui.GetCursorPosY();
using (ImRaii.PushId("transfers")) DrawTransfers(); bool drewFooter;
_transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight(); 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-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs);
using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups); using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups);
using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw(); using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw();
@@ -330,10 +337,9 @@ public class CompactUi : WindowMediatorSubscriberBase
private void DrawPairs() private void DrawPairs()
{ {
float ySize = Math.Abs(_transferPartHeight) < 0.0001f float ySize = !_hasFooterPartHeight
? 1 ? 1
: (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y : MathF.Max(1f, ImGui.GetContentRegionAvail().Y - _footerPartHeight);
+ ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY();
if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false)) if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false))
{ {
@@ -346,101 +352,6 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.EndChild(); 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 #endregion
#region Header Drawing #region Header Drawing
@@ -1147,13 +1058,4 @@ public class CompactUi : WindowMediatorSubscriberBase
#endregion #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
} }

View File

@@ -39,7 +39,8 @@ public abstract class DrawFolderBase : IDrawFolder
public void Draw() public void Draw()
{ {
if (!RenderIfEmpty && !DrawPairs.Any()) return; var drawPairCount = DrawPairs.Count;
if (!RenderIfEmpty && drawPairCount == 0) return;
_suppressNextRowToggle = false; _suppressNextRowToggle = false;
@@ -111,9 +112,9 @@ public abstract class DrawFolderBase : IDrawFolder
if (_tagHandler.IsTagOpen(_id)) if (_tagHandler.IsTagOpen(_id))
{ {
using var indent = ImRaii.PushIndent(_uiSharedService.GetIconSize(FontAwesomeIcon.EllipsisV).X + ImGui.GetStyle().ItemSpacing.X, false); 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()) while (clipper.Step())
{ {
for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++)

View File

@@ -22,13 +22,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
private readonly ApiController _apiController; private readonly ApiController _apiController;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi; private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi; private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly HashSet<string> _onlinePairBuffer = new(StringComparer.Ordinal);
private IImmutableList<DrawUserPair>? _drawPairsCache;
private int? _totalPairsCache;
private bool _wasHovered = false; private bool _wasHovered = false;
private float _menuWidth; private float _menuWidth;
private bool _rowClickArmed; private bool _rowClickArmed;
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList(); public IImmutableList<DrawUserPair> DrawPairs => _drawPairsCache ??= _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 OnlinePairs => CountOnlinePairs(DrawPairs);
public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs); 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) 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); 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 color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
var allowRowClick = string.IsNullOrEmpty(_tag); var allowRowClick = string.IsNullOrEmpty(_tag);
var suppressRowToggle = false; var suppressRowToggle = false;
@@ -85,10 +92,10 @@ public class DrawGroupedGroupFolder : IDrawFolder
{ {
ImGui.SameLine(); ImGui.SameLine();
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]"); ImGui.TextUnformatted("[" + onlinePairs.ToString() + "]");
} }
UiSharedService.AttachToolTip(OnlinePairs + " online in all of your joined syncshells" + Environment.NewLine + UiSharedService.AttachToolTip(onlinePairs + " online in all of your joined syncshells" + Environment.NewLine +
TotalPairs + " pairs combined in all of your joined syncshells"); totalPairs + " pairs combined in all of your joined syncshells");
ImGui.SameLine(); ImGui.SameLine();
ImGui.AlignTextToFramePadding(); ImGui.AlignTextToFramePadding();
if (_tag != "") if (_tag != "")
@@ -96,7 +103,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.TextUnformatted(_tag); ImGui.TextUnformatted(_tag);
ImGui.SameLine(); ImGui.SameLine();
DrawPauseButton(); DrawPauseButton(hasPairs);
ImGui.SameLine(); ImGui.SameLine();
DrawMenu(ref suppressRowToggle); DrawMenu(ref suppressRowToggle);
} else } else
@@ -104,7 +111,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.TextUnformatted("All Syncshells"); ImGui.TextUnformatted("All Syncshells");
ImGui.SameLine(); ImGui.SameLine();
DrawPauseButton(); DrawPauseButton(hasPairs);
} }
} }
color.Dispose(); 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()); var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause; 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() protected void ChangePauseStateGroups()
{ {
foreach(var group in _groups) foreach(var group in _groups)

View File

@@ -340,7 +340,10 @@ public class DrawUserPair
? FontAwesomeIcon.User : FontAwesomeIcon.Users); ? FontAwesomeIcon.User : FontAwesomeIcon.Users);
} }
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
UiSharedService.AttachToolTip(GetUserTooltip()); UiSharedService.AttachToolTip(GetUserTooltip());
}
if (_performanceConfigService.Current.ShowPerformanceIndicator if (_performanceConfigService.Current.ShowPerformanceIndicator
&& !_performanceConfigService.Current.UIDsToIgnore && !_performanceConfigService.Current.UIDsToIgnore
@@ -354,6 +357,8 @@ public class DrawUserPair
_uiSharedService.IconText(FontAwesomeIcon.ExclamationTriangle, UIColors.Get("LightlessYellow")); _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; string userWarningText = "WARNING: This user exceeds one or more of your defined thresholds:" + UiSharedService.TooltipSeparator;
bool shownVram = false; bool shownVram = false;
if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0 if (_performanceConfigService.Current.VRAMSizeWarningThresholdMiB > 0
@@ -371,6 +376,7 @@ public class DrawUserPair
UiSharedService.AttachToolTip(userWarningText); UiSharedService.AttachToolTip(userWarningText);
} }
}
ImGui.SameLine(); ImGui.SameLine();
@@ -613,12 +619,15 @@ public class DrawUserPair
perm.SetPaused(!perm.IsPaused()); perm.SetPaused(!perm.IsPaused());
_ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm)); _ = _apiController.UserSetPairPermissions(new(_pair.UserData, perm));
} }
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused() UiSharedService.AttachToolTip(!_pair.UserPair!.OwnPermissions.IsPaused()
? ("Pause pairing with " + _pair.UserData.AliasOrUID ? ("Pause pairing with " + _pair.UserData.AliasOrUID
+ (_pair.UserPair!.OwnPermissions.IsSticky() + (_pair.UserPair!.OwnPermissions.IsSticky()
? string.Empty ? 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.")) : 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); : "Resume pairing with " + _pair.UserData.AliasOrUID);
}
if (_pair.IsPaired) if (_pair.IsPaired)
{ {
@@ -781,8 +790,11 @@ public class DrawUserPair
currentRightSide -= (_uiSharedService.GetIconSize(FontAwesomeIcon.Running).X + (spacingX / 2f)); currentRightSide -= (_uiSharedService.GetIconSize(FontAwesomeIcon.Running).X + (spacingX / 2f));
ImGui.SameLine(currentRightSide); ImGui.SameLine(currentRightSide);
_uiSharedService.IconText(FontAwesomeIcon.Running); _uiSharedService.IconText(FontAwesomeIcon.Running);
if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
{
UiSharedService.AttachToolTip($"This user has shared {sharedData.Count} Character Data Sets with you." + UiSharedService.TooltipSeparator 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."); + "Click to open the Character Data Hub and show the entries.");
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
{ {
_mediator.Publish(new OpenCharaDataHubWithFilterMessage(_pair.UserData)); _mediator.Publish(new OpenCharaDataHubWithFilterMessage(_pair.UserData));

View 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";
}
}

View 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);
}

View File

@@ -325,16 +325,13 @@ public class DownloadUi : WindowMediatorSubscriberBase
if (hasValidSize) if (hasValidSize)
{ {
if (dlProg > 0) fillPercent = totalBytes > 0 ? transferredBytes / (double)totalBytes : 0.0;
{ if (isAllComplete && totalBytes > 0)
fillPercent = transferredBytes / (double)totalBytes;
showFill = true;
}
else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes)
{ {
fillPercent = 1.0; fillPercent = 1.0;
showFill = true;
} }
showFill = transferredBytes > 0 || isAllComplete;
} }
if (showFill) if (showFill)

View File

@@ -25,6 +25,7 @@ using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator; using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing; using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration; using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components;
using LightlessSync.UI.Models; using LightlessSync.UI.Models;
using LightlessSync.UI.Services; using LightlessSync.UI.Services;
using LightlessSync.UI.Style; using LightlessSync.UI.Style;
@@ -66,6 +67,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly PairUiService _pairUiService; private readonly PairUiService _pairUiService;
private readonly PerformanceCollectorService _performanceCollector; private readonly PerformanceCollectorService _performanceCollector;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly OptimizationSettingsPanel _optimizationSettingsPanel;
private readonly PairProcessingLimiter _pairProcessingLimiter; private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly ServerConfigurationManager _serverConfigurationManager; private readonly ServerConfigurationManager _serverConfigurationManager;
@@ -133,6 +135,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly Dictionary<string, double> _generalTreeHighlights = new(StringComparer.Ordinal); private readonly Dictionary<string, double> _generalTreeHighlights = new(StringComparer.Ordinal);
private const float GeneralTreeHighlightDuration = 1.5f; private const float GeneralTreeHighlightDuration = 1.5f;
private readonly SeluneBrush _generalSeluneBrush = new(); 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[] private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[]
{ {
@@ -208,6 +216,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_httpClient = httpClient; _httpClient = httpClient;
_fileCompactor = fileCompactor; _fileCompactor = fileCompactor;
_uiShared = uiShared; _uiShared = uiShared;
_optimizationSettingsPanel = new OptimizationSettingsPanel(_uiShared, _playerPerformanceConfigService, _pairUiService);
_nameplateService = nameplateService; _nameplateService = nameplateService;
_actorObjectService = actorObjectService; _actorObjectService = actorObjectService;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v); _validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
@@ -229,6 +238,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
_selectGeneralTabOnNextDraw = true; _selectGeneralTabOnNextDraw = true;
FocusGeneralTree("Lightfinder"); FocusGeneralTree("Lightfinder");
}); });
Mediator.Subscribe<OpenPerformanceSettingsMessage>(this, msg =>
{
IsOpen = true;
FocusPerformanceSection(msg.Section);
});
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false); Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart()); Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd()); 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) private void DrawThemeVectorRow(MainStyle.StyleVector2Option option)
{ {
ImGui.TableNextRow(); ImGui.TableNextRow();
@@ -1593,6 +1451,24 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SameLine(); ImGui.SameLine();
ImGui.TextColored(statusColor, $"[{(pair.IsVisible ? "Visible" : pair.IsOnline ? "Online" : "Offline")}]"); 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)) if (ImGui.BeginTable("##pairDebugProperties", 2, ImGuiTableFlags.SizingStretchProp))
{ {
DrawPairPropertyRow("UID", pair.UserData.UID); DrawPairPropertyRow("UID", pair.UserData.UID);
@@ -1722,6 +1598,141 @@ public class SettingsUi : WindowMediatorSubscriberBase
DrawPairEventLog(pair); 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) private static IEnumerable<string> GetGroupInfoFlags(GroupPairUserInfo info)
{ {
if (info.HasFlag(GroupPairUserInfo.IsModerator)) 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 events = _eventAggregator.EventList.Value;
var alias = pair.UserData.Alias; var alias = pair.UserData.Alias;
var aliasOrUid = pair.UserData.AliasOrUID; var aliasOrUid = pair.UserData.AliasOrUID;
var rawUid = pair.UserData.UID; var rawUid = pair.UserData.UID;
var playerName = pair.PlayerName; var playerName = pair.PlayerName;
var relevantEvents = events.Where(e => return events.Where(e =>
EventMatchesIdentifier(e, rawUid) EventMatchesIdentifier(e, rawUid)
|| EventMatchesIdentifier(e, aliasOrUid) || EventMatchesIdentifier(e, aliasOrUid)
|| EventMatchesIdentifier(e, alias) || EventMatchesIdentifier(e, alias)
|| (!string.IsNullOrEmpty(playerName) && string.Equals(e.Character, playerName, StringComparison.OrdinalIgnoreCase))) || (!string.IsNullOrEmpty(playerName) && string.Equals(e.Character, playerName, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(e => e.EventTime) .OrderByDescending(e => e.EventTime)
.Take(40) .Take(maxEvents)
.ToList(); .ToList();
}
private void DrawPairEventLog(Pair pair)
{
ImGui.TextUnformatted("Recent Events");
var relevantEvents = GetRelevantPairEvents(pair, 40);
if (relevantEvents.Count == 0) if (relevantEvents.Count == 0)
{ {
@@ -2290,11 +2306,29 @@ public class SettingsUi : WindowMediatorSubscriberBase
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately; var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye; var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
var enableParticleEffects = _configService.Current.EnableParticleEffects; var enableParticleEffects = _configService.Current.EnableParticleEffects;
var showUiWhenUiHidden = _configService.Current.ShowUiWhenUiHidden;
var showUiInGpose = _configService.Current.ShowUiInGpose;
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple"))) using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
{ {
if (behaviorTree.Visible) 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)) if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects))
{ {
_configService.Current.EnableParticleEffects = enableParticleEffects; _configService.Current.EnableParticleEffects = enableParticleEffects;
@@ -3401,6 +3435,43 @@ public class SettingsUi : WindowMediatorSubscriberBase
_generalTreeHighlights[label] = ImGui.GetTime(); _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) private float GetGeneralTreeHighlightAlpha(string label)
{ {
if (!_generalTreeHighlights.TryGetValue(label, out var startTime)) if (!_generalTreeHighlights.TryGetValue(label, out var startTime))
@@ -3490,7 +3561,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool showPerformanceIndicator = _playerPerformanceConfigService.Current.ShowPerformanceIndicator; 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)) if (ImGui.Checkbox("Show performance indicator", ref showPerformanceIndicator))
{ {
@@ -3586,7 +3657,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat; bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat;
bool autoPauseWhilePerforming = _playerPerformanceConfigService.Current.PauseWhilePerforming; 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)) if (ImGui.Checkbox("Auto pause sync while combat", ref autoPauseInCombat))
{ {
@@ -3683,261 +3754,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator(); ImGui.Separator();
if (_uiShared.MediumTreeNode("Texture Optimization", UIColors.Get("LightlessYellow"))) _optimizationSettingsPanel.DrawSettingsTrees(
{ PerformanceTextureOptimizationLabel,
_uiShared.MediumText("Warning", UIColors.Get("DimRed")); UIColors.Get("LightlessYellow"),
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"), PerformanceModelOptimizationLabel,
new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "), UIColors.Get("LightlessOrange"),
new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true), BeginPerformanceTree);
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();
}
ImGui.Separator(); ImGui.Separator();
ImGui.Dummy(new Vector2(10)); ImGui.Dummy(new Vector2(10));

View File

@@ -40,9 +40,9 @@ internal static class MainStyle
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg), 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.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.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.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive), new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed), 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.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg), new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),

View File

@@ -29,6 +29,7 @@ public sealed class SeluneGradientSettings
public Vector4 GradientColor { get; init; } = UIColors.Get("LightlessPurple"); public Vector4 GradientColor { get; init; } = UIColors.Get("LightlessPurple");
public Vector4? HighlightColor { get; init; } public Vector4? HighlightColor { get; init; }
public float GradientPeakOpacity { get; init; } = 0.07f; public float GradientPeakOpacity { get; init; } = 0.07f;
public float GradientPeakPosition { get; init; } = 0.035f;
public float HighlightPeakAlpha { get; init; } = 0.13f; public float HighlightPeakAlpha { get; init; } = 0.13f;
public float HighlightEdgeAlpha { get; init; } = 0f; public float HighlightEdgeAlpha { get; init; } = 0f;
public float HighlightMidpoint { get; init; } = 0.45f; public float HighlightMidpoint { get; init; } = 0.45f;
@@ -378,6 +379,7 @@ internal static class SeluneRenderer
topColorVec, topColorVec,
midColorVec, midColorVec,
bottomColorVec, bottomColorVec,
settings,
settings.BackgroundMode); settings.BackgroundMode);
} }
@@ -403,19 +405,21 @@ internal static class SeluneRenderer
Vector4 topColorVec, Vector4 topColorVec,
Vector4 midColorVec, Vector4 midColorVec,
Vector4 bottomColorVec, Vector4 bottomColorVec,
SeluneGradientSettings settings,
SeluneGradientMode mode) SeluneGradientMode mode)
{ {
var peakPosition = Math.Clamp(settings.GradientPeakPosition, 0.01f, 0.99f);
switch (mode) switch (mode)
{ {
case SeluneGradientMode.Vertical: case SeluneGradientMode.Vertical:
DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); DrawVerticalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition);
break; break;
case SeluneGradientMode.Horizontal: case SeluneGradientMode.Horizontal:
DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec); DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition);
break; break;
case SeluneGradientMode.Both: case SeluneGradientMode.Both:
DrawVerticalBackground(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); DrawHorizontalBackground(drawList, gradientLeft, gradientRight, clampedTopY, clampedBottomY, topColorVec, midColorVec, bottomColorVec, peakPosition);
break; break;
} }
} }
@@ -428,13 +432,14 @@ internal static class SeluneRenderer
float clampedBottomY, float clampedBottomY,
Vector4 topColorVec, Vector4 topColorVec,
Vector4 midColorVec, Vector4 midColorVec,
Vector4 bottomColorVec) Vector4 bottomColorVec,
float peakPosition)
{ {
var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec); var topColor = ImGui.ColorConvertFloat4ToU32(topColorVec);
var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec);
var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec); var bottomColor = ImGui.ColorConvertFloat4ToU32(bottomColorVec);
var midY = clampedTopY + (clampedBottomY - clampedTopY) * 0.035f; var midY = clampedTopY + (clampedBottomY - clampedTopY) * peakPosition;
drawList.AddRectFilledMultiColor( drawList.AddRectFilledMultiColor(
new Vector2(gradientLeft, clampedTopY), new Vector2(gradientLeft, clampedTopY),
new Vector2(gradientRight, midY), new Vector2(gradientRight, midY),
@@ -460,13 +465,14 @@ internal static class SeluneRenderer
float clampedBottomY, float clampedBottomY,
Vector4 leftColorVec, Vector4 leftColorVec,
Vector4 midColorVec, Vector4 midColorVec,
Vector4 rightColorVec) Vector4 rightColorVec,
float peakPosition)
{ {
var leftColor = ImGui.ColorConvertFloat4ToU32(leftColorVec); var leftColor = ImGui.ColorConvertFloat4ToU32(leftColorVec);
var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec); var midColor = ImGui.ColorConvertFloat4ToU32(midColorVec);
var rightColor = ImGui.ColorConvertFloat4ToU32(rightColorVec); var rightColor = ImGui.ColorConvertFloat4ToU32(rightColorVec);
var midX = gradientLeft + (gradientRight - gradientLeft) * 0.035f; var midX = gradientLeft + (gradientRight - gradientLeft) * peakPosition;
drawList.AddRectFilledMultiColor( drawList.AddRectFilledMultiColor(
new Vector2(gradientLeft, clampedTopY), new Vector2(gradientLeft, clampedTopY),
new Vector2(midX, clampedBottomY), new Vector2(midX, clampedBottomY),

File diff suppressed because it is too large Load Diff

View 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;
}
}
}
}

View 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

View File

@@ -617,6 +617,12 @@
"resolved": "10.0.1", "resolved": "10.0.1",
"contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw==" "contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw=="
}, },
"lightlesscompactor": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.1, )"
}
},
"lightlesssync.api": { "lightlesssync.api": {
"type": "Project", "type": "Project",
"dependencies": { "dependencies": {