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

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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pictomancy", "ffxiv_pictomancy\Pictomancy\Pictomancy.csproj", "{825F17D8-2704-24F6-DF8B-2542AC92C765}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactor", "LightlessCompactor\LightlessCompactor.csproj", "{01F31917-9F1E-426D-BDAE-17268CBF9523}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LightlessCompactorWorker", "LightlessCompactorWorker\LightlessCompactorWorker.csproj", "{72BE3664-CD0E-4DA4-B040-91338A2798E0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -116,6 +120,30 @@ Global
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x64.Build.0 = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.ActiveCfg = Release|x64
{825F17D8-2704-24F6-DF8B-2542AC92C765}.Release|x86.Build.0 = Release|x64
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|Any CPU.Build.0 = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.ActiveCfg = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x64.Build.0 = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.ActiveCfg = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Debug|x86.Build.0 = Debug|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.ActiveCfg = Release|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|Any CPU.Build.0 = Release|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.ActiveCfg = Release|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x64.Build.0 = Release|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.ActiveCfg = Release|Any CPU
{01F31917-9F1E-426D-BDAE-17268CBF9523}.Release|x86.Build.0 = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.ActiveCfg = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x64.Build.0 = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.ActiveCfg = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Debug|x86.Build.0 = Debug|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|Any CPU.Build.0 = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.ActiveCfg = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x64.Build.0 = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.ActiveCfg = Release|Any CPU
{72BE3664-CD0E-4DA4-B040-91338A2798E0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

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;
}
private static bool TryGetHashFromFileName(FileInfo fileInfo, out string hash)
{
hash = Path.GetFileNameWithoutExtension(fileInfo.Name);
if (string.IsNullOrWhiteSpace(hash))
{
return false;
}
if (hash.Length is not (40 or 64))
{
return false;
}
for (var i = 0; i < hash.Length; i++)
{
var c = hash[i];
var isHex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!isHex)
{
return false;
}
}
hash = hash.ToUpperInvariant();
return true;
}
private static string BuildVersionHeader() => $"{FileCacheVersionHeaderPrefix}{FileCacheVersion}";
private static bool TryParseVersionHeader(string? line, out int version)
@@ -288,6 +317,11 @@ public sealed class FileCacheManager : IHostedService
_logger.LogTrace("Creating cache entry for {path}", path);
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder)) return null;
if (TryGetHashFromFileName(fi, out var hash))
{
return CreateCacheEntryWithKnownHash(fi.FullName, hash);
}
return CreateFileEntity(cacheFolder, CachePrefix, fi);
}

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

View File

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

View File

@@ -2,9 +2,9 @@ using Dalamud.Plugin;
using LightlessSync.Interop.Ipc.Framework;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services;
using LightlessSync.Services.ActorTracking;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Logging;
using System.Globalization;
using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers;
@@ -12,7 +12,6 @@ namespace LightlessSync.Interop.Ipc.Penumbra;
public sealed class PenumbraResource : PenumbraBase
{
private readonly ActorObjectService _actorObjectService;
private readonly GetGameObjectResourcePaths _gameObjectResourcePaths;
private readonly ResolveGameObjectPath _resolveGameObjectPath;
private readonly ReverseResolveGameObjectPath _reverseResolveGameObjectPath;
@@ -24,10 +23,8 @@ public sealed class PenumbraResource : PenumbraBase
ILogger logger,
IDalamudPluginInterface pluginInterface,
DalamudUtilService dalamudUtil,
LightlessMediator mediator,
ActorObjectService actorObjectService) : base(logger, pluginInterface, dalamudUtil, mediator)
LightlessMediator mediator) : base(logger, pluginInterface, dalamudUtil, mediator)
{
_actorObjectService = actorObjectService;
_gameObjectResourcePaths = new GetGameObjectResourcePaths(pluginInterface);
_resolveGameObjectPath = new ResolveGameObjectPath(pluginInterface);
_reverseResolveGameObjectPath = new ReverseResolveGameObjectPath(pluginInterface);
@@ -79,23 +76,11 @@ public sealed class PenumbraResource : PenumbraBase
private void HandleResourceLoaded(nint ptr, string gamePath, string resolvedPath)
{
if (ptr == nint.Zero)
if (ptr != nint.Zero && string.Compare(gamePath, resolvedPath, ignoreCase: true, CultureInfo.InvariantCulture) != 0)
{
return;
}
if (!_actorObjectService.TryGetOwnedKind(ptr, out _))
{
return;
}
if (string.Compare(gamePath, resolvedPath, StringComparison.OrdinalIgnoreCase) == 0)
{
return;
}
Mediator.Publish(new PenumbraResourceLoadMessage(ptr, gamePath, resolvedPath));
}
}
protected override void HandleStateChange(IpcConnectionState previous, IpcConnectionState current)
{

View File

@@ -26,7 +26,7 @@ public sealed class PenumbraTexture : PenumbraBase
public override string Name => "Penumbra.Textures";
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token)
public async Task ConvertTextureFilesAsync(ILogger logger, IReadOnlyList<TextureConversionJob> jobs, IProgress<TextureConversionProgress>? progress, CancellationToken token, bool requestRedraw)
{
if (!IsAvailable || jobs.Count == 0)
{
@@ -57,7 +57,7 @@ public sealed class PenumbraTexture : PenumbraBase
Mediator.Publish(new ResumeScanMessage(nameof(ConvertTextureFilesAsync)));
}
if (completedJobs > 0 && !token.IsCancellationRequested)
if (requestRedraw && completedJobs > 0 && !token.IsCancellationRequested)
{
await DalamudUtil.RunOnFrameworkThread(async () =>
{

View File

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

View File

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

View File

@@ -21,11 +21,14 @@ public class PlayerPerformanceConfig : ILightlessConfiguration
public bool EnableIndexTextureDownscale { get; set; } = false;
public int TextureDownscaleMaxDimension { get; set; } = 2048;
public bool OnlyDownscaleUncompressedTextures { get; set; } = true;
public bool EnableUncompressedTextureCompression { get; set; } = false;
public bool SkipUncompressedTextureCompressionMipMaps { get; set; } = false;
public bool KeepOriginalTextureFiles { get; set; } = false;
public bool SkipTextureDownscaleForPreferredPairs { get; set; } = true;
public bool EnableModelDecimation { get; set; } = false;
public int ModelDecimationTriangleThreshold { get; set; } = 20_000;
public int ModelDecimationTriangleThreshold { get; set; } = 15_000;
public double ModelDecimationTargetRatio { get; set; } = 0.8;
public bool ModelDecimationNormalizeTangents { get; set; } = true;
public bool KeepOriginalModelFiles { get; set; } = true;
public bool SkipModelDecimationForPreferredPairs { get; set; } = true;
public bool ModelDecimationAllowBody { get; set; } = false;

View File

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

View File

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

View File

@@ -476,7 +476,7 @@ public class PlayerDataFactory
if (transientPaths.Count == 0)
return (new Dictionary<string, string[]>(StringComparer.Ordinal), clearedReplacements);
var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet<string>(StringComparer.Ordinal))
var resolved = await GetFileReplacementsFromPaths(transientPaths, new HashSet<string>(StringComparer.Ordinal))
.ConfigureAwait(false);
if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries)
@@ -692,7 +692,6 @@ public class PlayerDataFactory
private async Task<IReadOnlyDictionary<string, string[]>> GetFileReplacementsFromPaths(
GameObjectHandler handler,
HashSet<string> forwardResolve,
HashSet<string> reverseResolve)
{
@@ -707,59 +706,6 @@ public class PlayerDataFactory
var reversePathsLower = reversePaths.Length == 0 ? Array.Empty<string>() : reversePaths.Select(p => p.ToLowerInvariant()).ToArray();
Dictionary<string, List<string>> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal);
if (handler.ObjectKind != ObjectKind.Player)
{
var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() =>
{
var idx = handler.GetGameObject()?.ObjectIndex;
if (!idx.HasValue)
return ((int?)null, Array.Empty<string>(), Array.Empty<string[]>());
var resolvedForward = new string[forwardPaths.Length];
for (int i = 0; i < forwardPaths.Length; i++)
resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value);
var resolvedReverse = new string[reversePaths.Length][];
for (int i = 0; i < reversePaths.Length; i++)
resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value);
return (idx, resolvedForward, resolvedReverse);
}).ConfigureAwait(false);
if (objectIndex.HasValue)
{
for (int i = 0; i < forwardPaths.Length; i++)
{
var filePath = forwardResolved[i]?.ToLowerInvariant();
if (string.IsNullOrEmpty(filePath))
continue;
if (resolvedPaths.TryGetValue(filePath, out var list))
list.Add(forwardPaths[i].ToLowerInvariant());
else
{
resolvedPaths[filePath] = [forwardPathsLower[i]];
}
}
for (int i = 0; i < reversePaths.Length; i++)
{
var filePath = reversePathsLower[i];
var reverseResolvedLower = new string[reverseResolved[i].Length];
for (var j = 0; j < reverseResolvedLower.Length; j++)
{
reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant();
}
if (resolvedPaths.TryGetValue(filePath, out var list))
list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant()));
else
resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()];
}
return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly();
}
}
var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false);
for (int i = 0; i < forwardPaths.Length; i++)

View File

@@ -36,6 +36,7 @@
void Initialize();
void ApplyData(CharacterData data);
void ApplyLastReceivedData(bool forced = false);
Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken);
bool FetchPerformanceMetricsFromCache();
void LoadCachedCharacterData(CharacterData data);
void SetUploading(bool uploading);

View File

@@ -54,6 +54,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
private readonly XivDataAnalyzer _modelAnalyzer;
private readonly PenumbraTempCollectionJanitor _tempCollectionJanitor;
private readonly LightlessConfigService _configService;
private readonly SemaphoreSlim _metricsComputeGate = new(1, 1);
private readonly PairManager _pairManager;
private readonly IFramework _framework;
private CancellationTokenSource? _applicationCancellationTokenSource;
@@ -193,8 +194,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
public string? LastFailureReason => _lastFailureReason;
public IReadOnlyList<string> LastBlockingConditions => _lastBlockingConditions;
public bool IsApplying => _applicationTask is { IsCompleted: false };
public bool IsDownloading => _downloadManager.IsDownloading;
public int PendingDownloadCount => _downloadManager.CurrentDownloads.Count;
public bool IsDownloading => _downloadManager.IsDownloadingFor(_charaHandler);
public int PendingDownloadCount => _downloadManager.GetPendingDownloadCount(_charaHandler);
public int ForbiddenDownloadCount => _downloadManager.ForbiddenTransfers.Count;
public PairHandlerAdapter(
@@ -721,6 +722,74 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
return true;
}
public async Task EnsurePerformanceMetricsAsync(CancellationToken cancellationToken)
{
EnsureInitialized();
if (LastReceivedCharacterData is null || IsApplying)
{
return;
}
if (LastAppliedApproximateVRAMBytes >= 0
&& LastAppliedDataTris >= 0
&& LastAppliedApproximateEffectiveVRAMBytes >= 0
&& LastAppliedApproximateEffectiveTris >= 0)
{
return;
}
await _metricsComputeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
cancellationToken.ThrowIfCancellationRequested();
if (LastReceivedCharacterData is null)
{
return;
}
if (LastAppliedApproximateVRAMBytes >= 0
&& LastAppliedDataTris >= 0
&& LastAppliedApproximateEffectiveVRAMBytes >= 0
&& LastAppliedApproximateEffectiveTris >= 0)
{
return;
}
var sanitized = CloneAndSanitizeLastReceived(out var dataHash);
if (sanitized is null)
{
return;
}
if (!string.IsNullOrEmpty(dataHash) && TryApplyCachedMetrics(dataHash))
{
_cachedData = sanitized;
_pairStateCache.Store(Ident, sanitized);
return;
}
if (LastAppliedApproximateVRAMBytes < 0 || LastAppliedApproximateEffectiveVRAMBytes < 0)
{
_playerPerformanceService.ComputeAndAutoPauseOnVRAMUsageThresholds(this, sanitized, []);
}
if (LastAppliedDataTris < 0 || LastAppliedApproximateEffectiveTris < 0)
{
await _playerPerformanceService.CheckTriangleUsageThresholds(this, sanitized).ConfigureAwait(false);
}
StorePerformanceMetrics(sanitized);
_cachedData = sanitized;
_pairStateCache.Store(Ident, sanitized);
}
finally
{
_metricsComputeGate.Release();
}
}
private CharacterData? CloneAndSanitizeLastReceived(out string? dataHash)
{
dataHash = null;
@@ -1090,6 +1159,19 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
Logger.LogDebug("[BASE-{appBase}] Setting data: {hash}, forceApplyMods: {force}", applicationBase, _cachedData.DataHash.Value, _forceApplyMods);
}
var forceModsForMissing = _pendingModReapply;
if (!forceModsForMissing && HasMissingCachedFiles(characterData))
{
forceModsForMissing = true;
}
if (forceModsForMissing)
{
_forceApplyMods = true;
}
var suppressForcedModRedrawOnForcedApply = suppressForcedModRedraw || forceModsForMissing;
SetUploading(false);
Logger.LogDebug("[BASE-{appbase}] Applying data for {player}, forceApplyCustomization: {forced}, forceApplyMods: {forceMods}", applicationBase, GetLogIdentifier(), forceApplyCustomization, _forceApplyMods);
@@ -1106,7 +1188,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
"Applying Character Data")));
var charaDataToUpdate = characterData.CheckUpdatedData(applicationBase, _cachedData?.DeepClone() ?? new(), Logger, this,
forceApplyCustomization, _forceApplyMods, suppressForcedModRedraw);
forceApplyCustomization, _forceApplyMods, suppressForcedModRedrawOnForcedApply);
if (handlerReady && _forceApplyMods)
{
@@ -1921,7 +2003,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
}
var handlerForDownload = _charaHandler;
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false));
_pairDownloadTask = Task.Run(async () => await _downloadManager.DownloadFiles(handlerForDownload, toDownloadReplacements, toDownloadFiles, downloadToken, skipDownscaleForPair, skipDecimationForPair).ConfigureAwait(false));
await _pairDownloadTask.ConfigureAwait(false);
@@ -2136,6 +2218,7 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
}
SplitPapMappings(moddedPaths, out var withoutPap, out var papOnly);
var hasPap = papOnly.Count > 0;
await _ipcManager.Penumbra.AssignTemporaryCollectionAsync(Logger, penumbraCollection, objIndex.Value).ConfigureAwait(false);
@@ -2148,6 +2231,8 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
if (handlerForApply.Address != nint.Zero)
await _actorObjectService.WaitForFullyLoadedAsync(handlerForApply.Address, token).ConfigureAwait(false);
if (hasPap)
{
var removedPap = await StripIncompatiblePapAsync(handlerForApply, charaData, papOnly, token).ConfigureAwait(false);
if (removedPap > 0)
{
@@ -2164,6 +2249,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
.ConfigureAwait(false);
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(merged, merged.Comparer);
}
else
{
_lastAppliedModdedPaths = new Dictionary<(string GamePath, string? Hash), string>(withoutPap, withoutPap.Comparer);
}
LastAppliedDataBytes = -1;
foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists))
@@ -2471,6 +2561,11 @@ internal sealed class PairHandlerAdapter : DisposableMediatorSubscriberBase, IPa
(item) =>
{
token.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(item.Hash))
{
Logger.LogTrace("[BASE-{appBase}] Skipping replacement with empty hash for paths: {paths}", applicationBase, string.Join(", ", item.GamePaths));
return;
}
var fileCache = _fileDbManager.GetFileCacheByHash(item.Hash);
if (fileCache is not null && !File.Exists(fileCache.ResolvedFilepath))
{

View File

@@ -271,7 +271,20 @@ public sealed class PairLedger : DisposableMediatorSubscriberBase
try
{
handler.ApplyLastReceivedData(forced: true);
_ = Task.Run(async () =>
{
try
{
await handler.EnsurePerformanceMetricsAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug(ex, "Failed to ensure performance metrics for {Ident}", handler.Ident);
}
}
});
}
catch (Exception ex)
{

View File

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

View File

@@ -571,36 +571,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (localPlayerAddress == nint.Zero)
return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero)
{
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (candidateAddress == nint.Zero)
return nint.Zero;
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
continue;
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion
? candidateAddress
: nint.Zero;
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
@@ -620,22 +603,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}
@@ -655,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return candidate;
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}

View File

@@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LightlessSync.UI.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LightlessSync.Services.Chat;
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
{
private const int MaxMessageHistory = 150;
private const int MaxMessageHistory = 200;
internal const int MaxOutgoingLength = 200;
private const int MaxUnreadCount = 999;
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
private const string ZoneChannelKey = "zone";
private const int MaxReportReasonLength = 100;
private const int MaxReportContextLength = 1000;
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtilService;
@@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
public Task StartAsync(CancellationToken cancellationToken)
{
LoadPersistedSyncshellHistory();
Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin());
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
@@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private void OnChatMessageReceived(ChatMessageDto dto)
{
var descriptor = dto.Channel.WithNormalizedCustomKey();
var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
var fromSelf = IsMessageFromSelf(dto, key);
var message = BuildMessage(dto, fromSelf);
ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
bool fromSelf = IsMessageFromSelf(dto, key);
ChatMessageEntry message = BuildMessage(dto, fromSelf);
bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications;
bool notifyMention = mentionNotificationsEnabled
&& !fromSelf
&& descriptor.Type == ChatChannelType.Group
&& TryGetSelfMentionToken(dto.Message, out _);
string? mentionChannelName = null;
string? mentionSenderName = null;
bool publishChannelList = false;
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
List<PersistedChatMessage>? persistedMessages = null;
string? persistedChannelKey = null;
using (_sync.EnterScope())
{
@@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.Messages.RemoveAt(0);
}
if (notifyMention)
{
mentionChannelName = state.DisplayName;
mentionSenderName = message.DisplayName;
}
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{
state.HasUnread = false;
@@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
}
MarkChannelsSnapshotDirtyLocked();
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
{
persistedChannelKey = state.Key;
persistedMessages = BuildPersistedHistoryLocked(state);
}
}
Mediator.Publish(new ChatChannelMessageAdded(key, message));
if (persistedMessages is not null && persistedChannelKey is not null)
{
PersistSyncshellHistory(persistedChannelKey, persistedMessages);
}
if (notifyMention)
{
string channelName = mentionChannelName ?? "Syncshell";
string senderName = mentionSenderName ?? "Someone";
string notificationText = $"You were mentioned by {senderName} in {channelName}.";
Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info));
}
if (publishChannelList)
{
using (_sync.EnterScope())
@@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return false;
}
private bool TryGetSelfMentionToken(string message, out string matchedToken)
{
matchedToken = string.Empty;
if (string.IsNullOrWhiteSpace(message))
{
return false;
}
HashSet<string> tokens = BuildSelfMentionTokens();
if (tokens.Count == 0)
{
return false;
}
return TryFindMentionToken(message, tokens, out matchedToken);
}
private HashSet<string> BuildSelfMentionTokens()
{
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
string uid = _apiController.UID;
if (IsValidMentionToken(uid))
{
tokens.Add(uid);
}
string displayName = _apiController.DisplayName;
if (IsValidMentionToken(displayName))
{
tokens.Add(displayName);
}
return tokens;
}
private static bool IsValidMentionToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
for (int i = 0; i < value.Length; i++)
{
if (!IsMentionChar(value[i]))
{
return false;
}
}
return true;
}
private static bool TryFindMentionToken(string message, IReadOnlyCollection<string> tokens, out string matchedToken)
{
matchedToken = string.Empty;
if (tokens.Count == 0 || string.IsNullOrEmpty(message))
{
return false;
}
int index = 0;
while (index < message.Length)
{
if (message[index] != '@')
{
index++;
continue;
}
if (index > 0 && IsMentionChar(message[index - 1]))
{
index++;
continue;
}
int start = index + 1;
int end = start;
while (end < message.Length && IsMentionChar(message[end]))
{
end++;
}
if (end == start)
{
index++;
continue;
}
string token = message.Substring(start, end - start);
if (tokens.Contains(token))
{
matchedToken = token;
return true;
}
index = end;
}
return false;
}
private static bool IsMentionChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
}
private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
{
var displayName = ResolveDisplayName(dto, fromSelf);
@@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return 0;
}
private void LoadPersistedSyncshellHistory()
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count == 0)
{
return;
}
List<string> invalidKeys = new();
foreach (KeyValuePair<string, string> entry in persisted)
{
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
{
invalidKeys.Add(entry.Key);
continue;
}
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> persistedMessages))
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count > MaxMessageHistory)
{
int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory);
persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex);
}
List<ChatMessageEntry> restoredMessages = new(persistedMessages.Count);
foreach (PersistedChatMessage persistedMessage in persistedMessages)
{
if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage))
{
continue;
}
restoredMessages.Add(restoredMessage);
}
if (restoredMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
using (_sync.EnterScope())
{
_messageHistoryCache[entry.Key] = restoredMessages;
}
}
if (invalidKeys.Count > 0)
{
foreach (string key in invalidKeys)
{
persisted.Remove(key);
}
_chatConfigService.Save();
}
}
private List<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
{
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
List<PersistedChatMessage> persistedMessages = new(state.Messages.Count - startIndex);
for (int i = startIndex; i < state.Messages.Count; i++)
{
ChatMessageEntry entry = state.Messages[i];
if (entry.Payload is not { } payload)
{
continue;
}
persistedMessages.Add(new PersistedChatMessage(
payload.Message,
entry.DisplayName,
entry.FromSelf,
entry.ReceivedAtUtc,
payload.SentAtUtc));
}
return persistedMessages;
}
private void PersistSyncshellHistory(string channelKey, List<PersistedChatMessage> persistedMessages)
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persistedMessages.Count == 0)
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
string? base64 = EncodePersistedMessages(persistedMessages);
if (string.IsNullOrWhiteSpace(base64))
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
persisted[channelKey] = base64;
_chatConfigService.Save();
}
private static string? EncodePersistedMessages(List<PersistedChatMessage> persistedMessages)
{
if (persistedMessages.Count == 0)
{
return null;
}
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions);
return Convert.ToBase64String(jsonBytes);
}
private static bool TryDecodePersistedHistory(string base64, out List<PersistedChatMessage> persistedMessages)
{
persistedMessages = new List<PersistedChatMessage>();
if (string.IsNullOrWhiteSpace(base64))
{
return false;
}
try
{
byte[] jsonBytes = Convert.FromBase64String(base64);
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(jsonBytes, PersistedHistorySerializerOptions);
if (decoded is null)
{
return false;
}
persistedMessages = decoded;
return true;
}
catch
{
return false;
}
}
private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage)
{
restoredMessage = default;
string messageText = persistedMessage.Message;
DateTime sentAtUtc = persistedMessage.SentAtUtc;
if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy)
{
messageText = legacy.Message;
sentAtUtc = legacy.SentAtUtc;
}
if (string.IsNullOrWhiteSpace(messageText))
{
return false;
}
ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey);
ChatSenderDescriptor sender = new ChatSenderDescriptor(
ChatSenderKind.Anonymous,
string.Empty,
null,
null,
null,
false);
ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty);
restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc);
return true;
}
private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey)
{
if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Zone };
}
int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1)
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string typeValue = channelKey[..separatorIndex];
if (!int.TryParse(typeValue, out int parsedType))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string customKey = channelKey[(separatorIndex + 1)..];
ChatChannelType channelType = parsedType switch
{
(int)ChatChannelType.Zone => ChatChannelType.Zone,
(int)ChatChannelType.Group => ChatChannelType.Group,
_ => ChatChannelType.Group
};
return new ChatChannelDescriptor
{
Type = channelType,
CustomKey = customKey
};
}
public void ClearPersistedSyncshellHistory(bool clearLoadedMessages)
{
bool shouldPublish = false;
bool saveConfig = false;
using (_sync.EnterScope())
{
Dictionary<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
if (cache.Count > 0)
{
List<string> keysToRemove = new();
foreach (string key in cache.Keys)
{
if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal))
{
keysToRemove.Add(key);
}
}
foreach (string key in keysToRemove)
{
cache.Remove(key);
}
if (keysToRemove.Count > 0)
{
shouldPublish = true;
}
}
if (clearLoadedMessages)
{
foreach (ChatChannelState state in _channels.Values)
{
if (state.Type != ChatChannelType.Group)
{
continue;
}
if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread)
{
continue;
}
state.Messages.Clear();
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[state.Key] = 0;
shouldPublish = true;
}
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count > 0)
{
persisted.Clear();
saveConfig = true;
}
if (shouldPublish)
{
MarkChannelsSnapshotDirtyLocked();
}
}
if (saveConfig)
{
_chatConfigService.Save();
}
if (shouldPublish)
{
PublishChannelListChanged();
}
}
private sealed class ChatChannelState
{
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
@@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
bool IsOwner);
private readonly record struct PendingSelfMessage(string ChannelKey, string Message);
public sealed record PersistedChatMessage(
string Message = "",
string DisplayName = "",
bool FromSelf = false,
DateTime ReceivedAtUtc = default,
DateTime SentAtUtc = default,
[property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null);
}

View File

@@ -424,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId;
var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
if (ownerEntityId == 0) return candidateAddress;
if (playerAddress == _actorObjectService.LocalPlayerAddress)
{
var localOwned = _actorObjectService.LocalMinionOrMountAddress;
if (localOwned != nint.Zero)
{
return localOwned;
}
}
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion)
&& ResolveOwnerId(candidate) == ownerEntityId)
{
return candidateAddress;
}
}
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
if (ownedObject != nint.Zero)
{
return ownedObject;
}
return candidateAddress;
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
}
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -485,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
}
}
return FindOwnedPet(ownerEntityId, ownerAddress);
return IntPtr.Zero;
}
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
@@ -493,69 +462,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false);
}
private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func<DalamudObjectKind, bool> matchesKind)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (!matchesKind(obj.ObjectKind))
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
{
continue;
}
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
{
if (candidate == null)

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using System.Globalization;
namespace LightlessSync.Services.TextureCompression;
@@ -27,7 +28,9 @@ public sealed class TextureCompressionService
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw = true,
bool includeMipMaps = true)
{
if (requests.Count == 0)
{
@@ -48,7 +51,7 @@ public sealed class TextureCompressionService
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false);
completed++;
}
@@ -65,14 +68,16 @@ public sealed class TextureCompressionService
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw,
bool includeMipMaps)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
IncludeMipMaps: includeMipMaps,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
@@ -83,7 +88,7 @@ public sealed class TextureCompressionService
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
@@ -128,20 +133,47 @@ public sealed class TextureCompressionService
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash);
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (hasExpectedHash)
{
entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
}
entry ??= _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash);
_fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false);
var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
if (corrected is not null)
{
entry = corrected;
}
}
try
{
if (entry.IsCacheEntry)
{
var info = new FileInfo(path);
entry.Size = info.Length;
entry.CompressedSize = null;
entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
_fileCacheManager.UpdateHashedFile(entry, computeProperties: false);
}
else
{
_fileCacheManager.UpdateHashedFile(entry);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path);
@@ -149,6 +181,35 @@ public sealed class TextureCompressionService
}
}
private static bool TryGetExpectedHashFromPath(string path, out string hash)
{
hash = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrWhiteSpace(hash))
{
return false;
}
if (hash.Length is not (40 or 64))
{
return false;
}
for (var i = 0; i < hash.Length; i++)
{
var c = hash[i];
var isHex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!isHex)
{
return false;
}
}
hash = hash.ToUpperInvariant();
return true;
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");

View File

@@ -4,9 +4,11 @@ using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Utils;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Lumina.Data.Files;
@@ -30,10 +32,12 @@ public sealed class TextureDownscaleService
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager;
private readonly TextureCompressionService _textureCompressionService;
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
private readonly SemaphoreSlim _compressionSemaphore = new(1);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget>
{
@@ -68,12 +72,14 @@ public sealed class TextureDownscaleService
ILogger<TextureDownscaleService> logger,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager)
FileCacheManager fileCacheManager,
TextureCompressionService textureCompressionService)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
_textureCompressionService = textureCompressionService;
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
@@ -82,9 +88,9 @@ public sealed class TextureDownscaleService
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return;
_activeJobs[hash] = Task.Run(async () =>
_downscaleDeduplicator.GetOrStart(hash, async () =>
{
TextureMapKind mapKind;
try
@@ -98,7 +104,7 @@ public sealed class TextureDownscaleService
}
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
});
}
public bool ShouldScheduleDownscale(string filePath)
@@ -107,7 +113,9 @@ public sealed class TextureDownscaleService
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale;
return performanceConfig.EnableNonIndexTextureMipTrim
|| performanceConfig.EnableIndexTextureDownscale
|| performanceConfig.EnableUncompressedTextureCompression;
}
public string GetPreferredPath(string hash, string originalPath)
@@ -144,7 +152,7 @@ public sealed class TextureDownscaleService
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
if (_downscaleDeduplicator.TryGetExisting(hash, out var job))
{
pending.Add(job);
}
@@ -182,10 +190,18 @@ public sealed class TextureDownscaleService
targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
if (onlyDownscaleUncompressed && !headerInfo.HasValue)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash);
return;
}
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -196,6 +212,7 @@ public sealed class TextureDownscaleService
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -206,6 +223,7 @@ public sealed class TextureDownscaleService
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -213,6 +231,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -222,6 +241,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -229,10 +249,12 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
var sourceFormat = sourceScratch.Meta.Format;
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
@@ -248,17 +270,40 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra))
{
if (canReencodeWithPenumbra
&& await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace(
"Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.",
hash,
sourceFormat);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using (finalScratch)
{
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
}
catch (Exception ex)
{
TryDelete(destination);
@@ -277,7 +322,6 @@ public sealed class TextureDownscaleService
finally
{
_downscaleSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}
@@ -330,6 +374,157 @@ public sealed class TextureDownscaleService
}
}
private bool TryConvertForSave(
ScratchImage source,
DXGIFormat sourceFormat,
out ScratchImage result,
bool attemptPenumbraFallback)
{
var isCompressed = sourceFormat.IsCompressed();
var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm;
try
{
result = source.Convert(targetFormat);
return true;
}
catch (Exception ex)
{
var compressedFallback = attemptPenumbraFallback
? " Attempting Penumbra re-encode."
: " Skipping downscale.";
_logger.LogWarning(
ex,
"Failed to convert downscaled texture to {Format}.{Fallback}",
targetFormat,
isCompressed ? compressedFallback : " Falling back to B8G8R8A8.");
if (isCompressed)
{
result = default!;
return false;
}
result = source.Convert(DXGIFormat.B8G8R8A8UNorm);
return true;
}
}
private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target)
{
if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
target = default;
return false;
}
private async Task<bool> TryReencodeWithPenumbraAsync(
string hash,
string sourcePath,
string destination,
ScratchImage resizedScratch,
TextureCompressionTarget target)
{
try
{
using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, uncompressed);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash);
TryDelete(destination);
return false;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var request = new TextureCompressionRequest(destination, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target);
TryDelete(destination);
return false;
}
finally
{
_compressionSemaphore.Release();
}
RegisterDownscaledTexture(hash, sourcePath, destination);
_logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination);
return true;
}
private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo)
{
var performanceConfig = _playerPerformanceConfigService.Current;
if (!performanceConfig.EnableUncompressedTextureCompression)
{
return;
}
if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath))
{
return;
}
var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null);
if (!info.HasValue)
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash);
return;
}
if (IsBlockCompressedFormat(info.Value.Format))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash);
return;
}
var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath);
if (suggestion is null)
{
return;
}
var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target);
if (!_textureCompressionService.IsTargetSelectable(target))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target);
return;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps;
var request = new TextureCompressionRequest(texturePath, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath);
}
finally
{
_compressionSemaphore.Release();
}
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index;

View File

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

View File

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

View File

@@ -47,11 +47,14 @@ namespace MeshDecimator
private int[][] indices = null;
private Vector3[] normals = null;
private Vector4[] tangents = null;
private Vector4[] tangents2 = null;
private Vector2[][] uvs2D = null;
private Vector3[][] uvs3D = null;
private Vector4[][] uvs4D = null;
private Vector4[] colors = null;
private BoneWeight[] boneWeights = null;
private float[] positionWs = null;
private float[] normalWs = null;
private static readonly int[] emptyIndices = new int[0];
#endregion
@@ -168,6 +171,36 @@ namespace MeshDecimator
}
}
/// <summary>
/// Gets or sets the position W components for this mesh.
/// </summary>
public float[] PositionWs
{
get { return positionWs; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The position Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
positionWs = value;
}
}
/// <summary>
/// Gets or sets the normal W components for this mesh.
/// </summary>
public float[] NormalWs
{
get { return normalWs; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The normal Ws must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
normalWs = value;
}
}
/// <summary>
/// Gets or sets the tangents for this mesh.
/// </summary>
@@ -183,6 +216,21 @@ namespace MeshDecimator
}
}
/// <summary>
/// Gets or sets the second tangent set for this mesh.
/// </summary>
public Vector4[] Tangents2
{
get { return tangents2; }
set
{
if (value != null && value.Length != vertices.Length)
throw new ArgumentException(string.Format("The second vertex tangents must be as many as the vertices. Assigned: {0} Require: {1}", value.Length, vertices.Length));
tangents2 = value;
}
}
/// <summary>
/// Gets or sets the first UV set for this mesh.
/// </summary>
@@ -298,11 +346,14 @@ namespace MeshDecimator
{
normals = null;
tangents = null;
tangents2 = null;
uvs2D = null;
uvs3D = null;
uvs4D = null;
colors = null;
boneWeights = null;
positionWs = null;
normalWs = null;
}
#endregion

View File

@@ -28,7 +28,6 @@ using System.Collections.Immutable;
using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LightlessSync.UI;
@@ -71,6 +70,7 @@ public class CompactUi : WindowMediatorSubscriberBase
private readonly SelectTagForSyncshellUi _selectTagForSyncshellUi;
private readonly SeluneBrush _seluneBrush = new();
private readonly TopTabMenu _tabMenu;
private readonly OptimizationSummaryCard _optimizationSummaryCard;
#endregion
@@ -86,7 +86,8 @@ public class CompactUi : WindowMediatorSubscriberBase
private int _pendingFocusFrame = -1;
private Pair? _pendingFocusPair;
private bool _showModalForUserAddition;
private float _transferPartHeight;
private float _footerPartHeight;
private bool _hasFooterPartHeight;
private bool _wasOpen;
private float _windowContentWidth;
@@ -177,6 +178,7 @@ public class CompactUi : WindowMediatorSubscriberBase
_characterAnalyzer = characterAnalyzer;
_playerPerformanceConfig = playerPerformanceConfig;
_lightlessMediator = mediator;
_optimizationSummaryCard = new OptimizationSummaryCard(_uiSharedService, _pairUiService, _playerPerformanceConfig, _fileTransferManager, _lightlessMediator);
}
#endregion
@@ -262,12 +264,17 @@ public class CompactUi : WindowMediatorSubscriberBase
using (ImRaii.PushId("global-topmenu")) _tabMenu.Draw(pairSnapshot);
using (ImRaii.PushId("pairlist")) DrawPairs();
var transfersTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, transfersTop - style.ItemSpacing.Y - gradientInset);
var footerTop = ImGui.GetCursorScreenPos().Y;
var gradientBottom = MathF.Max(gradientTop, footerTop - style.ItemSpacing.Y - gradientInset);
selune.DrawGradient(gradientTop, gradientBottom, ImGui.GetIO().DeltaTime);
float pairlistEnd = ImGui.GetCursorPosY();
using (ImRaii.PushId("transfers")) DrawTransfers();
_transferPartHeight = ImGui.GetCursorPosY() - pairlistEnd - ImGui.GetTextLineHeight();
bool drewFooter;
using (ImRaii.PushId("optimization-summary"))
{
drewFooter = _optimizationSummaryCard.Draw(_currentDownloads.Count);
}
_footerPartHeight = drewFooter ? ImGui.GetCursorPosY() - pairlistEnd : 0f;
_hasFooterPartHeight = true;
using (ImRaii.PushId("group-pair-popup")) _selectPairsForGroupUi.Draw(pairSnapshot.DirectPairs);
using (ImRaii.PushId("group-syncshell-popup")) _selectSyncshellForTagUi.Draw(pairSnapshot.Groups);
using (ImRaii.PushId("group-pair-edit")) _renamePairTagUi.Draw();
@@ -330,10 +337,9 @@ public class CompactUi : WindowMediatorSubscriberBase
private void DrawPairs()
{
float ySize = Math.Abs(_transferPartHeight) < 0.0001f
float ySize = !_hasFooterPartHeight
? 1
: (ImGui.GetWindowContentRegionMax().Y - ImGui.GetWindowContentRegionMin().Y
+ ImGui.GetTextLineHeight() - ImGui.GetStyle().WindowPadding.Y - ImGui.GetStyle().WindowBorderSize) - _transferPartHeight - ImGui.GetCursorPosY();
: MathF.Max(1f, ImGui.GetContentRegionAvail().Y - _footerPartHeight);
if (ImGui.BeginChild("list", new Vector2(_windowContentWidth, ySize), border: false))
{
@@ -346,101 +352,6 @@ public class CompactUi : WindowMediatorSubscriberBase
ImGui.EndChild();
}
private void DrawTransfers()
{
var currentUploads = _fileTransferManager.GetCurrentUploadsSnapshot();
ImGui.AlignTextToFramePadding();
_uiSharedService.IconText(FontAwesomeIcon.Upload);
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
if (currentUploads.Count > 0)
{
int totalUploads = currentUploads.Count;
int doneUploads = 0;
long totalUploaded = 0;
long totalToUpload = 0;
foreach (var upload in currentUploads)
{
if (upload.IsTransferred)
{
doneUploads++;
}
totalUploaded += upload.Transferred;
totalToUpload += upload.Total;
}
int activeUploads = totalUploads - doneUploads;
var uploadSlotLimit = Math.Clamp(_configService.Current.ParallelUploads, 1, 8);
ImGui.TextUnformatted($"{doneUploads}/{totalUploads} (slots {activeUploads}/{uploadSlotLimit})");
var uploadText = $"({UiSharedService.ByteToString(totalUploaded)}/{UiSharedService.ByteToString(totalToUpload)})";
var textSize = ImGui.CalcTextSize(uploadText);
ImGui.SameLine(_windowContentWidth - textSize.X);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(uploadText);
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("No uploads in progress");
}
var downloadSummary = GetDownloadSummary();
ImGui.AlignTextToFramePadding();
_uiSharedService.IconText(FontAwesomeIcon.Download);
ImGui.SameLine(35 * ImGuiHelpers.GlobalScale);
if (downloadSummary.HasDownloads)
{
var totalDownloads = downloadSummary.TotalFiles;
var doneDownloads = downloadSummary.TransferredFiles;
var totalDownloaded = downloadSummary.TransferredBytes;
var totalToDownload = downloadSummary.TotalBytes;
ImGui.TextUnformatted($"{doneDownloads}/{totalDownloads}");
var downloadText =
$"({UiSharedService.ByteToString(totalDownloaded)}/{UiSharedService.ByteToString(totalToDownload)})";
var textSize = ImGui.CalcTextSize(downloadText);
ImGui.SameLine(_windowContentWidth - textSize.X);
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted(downloadText);
}
else
{
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("No downloads in progress");
}
}
private DownloadSummary GetDownloadSummary()
{
long totalBytes = 0;
long transferredBytes = 0;
int totalFiles = 0;
int transferredFiles = 0;
foreach (var kvp in _currentDownloads.ToArray())
{
if (kvp.Value is not { Count: > 0 } statuses)
{
continue;
}
foreach (var status in statuses.Values)
{
totalBytes += status.TotalBytes;
transferredBytes += status.TransferredBytes;
totalFiles += status.TotalFiles;
transferredFiles += status.TransferredFiles;
}
}
return new DownloadSummary(totalFiles, transferredFiles, transferredBytes, totalBytes);
}
#endregion
#region Header Drawing
@@ -1147,13 +1058,4 @@ public class CompactUi : WindowMediatorSubscriberBase
#endregion
#region Helper Types
[StructLayout(LayoutKind.Auto)]
private readonly record struct DownloadSummary(int TotalFiles, int TransferredFiles, long TransferredBytes, long TotalBytes)
{
public bool HasDownloads => TotalFiles > 0 || TotalBytes > 0;
}
#endregion
}

View File

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

View File

@@ -22,13 +22,16 @@ public class DrawGroupedGroupFolder : IDrawFolder
private readonly ApiController _apiController;
private readonly SelectSyncshellForTagUi _selectSyncshellForTagUi;
private readonly RenameSyncshellTagUi _renameSyncshellTagUi;
private readonly HashSet<string> _onlinePairBuffer = new(StringComparer.Ordinal);
private IImmutableList<DrawUserPair>? _drawPairsCache;
private int? _totalPairsCache;
private bool _wasHovered = false;
private float _menuWidth;
private bool _rowClickArmed;
public IImmutableList<DrawUserPair> DrawPairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).Where(g => g.Pair.IsOnline).DistinctBy(g => g.Pair.UserData.UID).Count();
public int TotalPairs => _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
public IImmutableList<DrawUserPair> DrawPairs => _drawPairsCache ??= _groups.SelectMany(g => g.GroupDrawFolder.DrawPairs).ToImmutableList();
public int OnlinePairs => CountOnlinePairs(DrawPairs);
public int TotalPairs => _totalPairsCache ??= _groups.Sum(g => g.GroupDrawFolder.TotalPairs);
public DrawGroupedGroupFolder(IEnumerable<GroupFolder> groups, TagHandler tagHandler, ApiController apiController, UiSharedService uiSharedService, SelectSyncshellForTagUi selectSyncshellForTagUi, RenameSyncshellTagUi renameSyncshellTagUi, string tag)
{
@@ -50,6 +53,10 @@ public class DrawGroupedGroupFolder : IDrawFolder
}
using var id = ImRaii.PushId(_id);
var drawPairs = DrawPairs;
var onlinePairs = CountOnlinePairs(drawPairs);
var totalPairs = TotalPairs;
var hasPairs = drawPairs.Count > 0;
var color = ImRaii.PushColor(ImGuiCol.ChildBg, ImGui.GetColorU32(ImGuiCol.FrameBgHovered), _wasHovered);
var allowRowClick = string.IsNullOrEmpty(_tag);
var suppressRowToggle = false;
@@ -85,10 +92,10 @@ public class DrawGroupedGroupFolder : IDrawFolder
{
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
ImGui.TextUnformatted("[" + OnlinePairs.ToString() + "]");
ImGui.TextUnformatted("[" + onlinePairs.ToString() + "]");
}
UiSharedService.AttachToolTip(OnlinePairs + " online in all of your joined syncshells" + Environment.NewLine +
TotalPairs + " pairs combined in all of your joined syncshells");
UiSharedService.AttachToolTip(onlinePairs + " online in all of your joined syncshells" + Environment.NewLine +
totalPairs + " pairs combined in all of your joined syncshells");
ImGui.SameLine();
ImGui.AlignTextToFramePadding();
if (_tag != "")
@@ -96,7 +103,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.TextUnformatted(_tag);
ImGui.SameLine();
DrawPauseButton();
DrawPauseButton(hasPairs);
ImGui.SameLine();
DrawMenu(ref suppressRowToggle);
} else
@@ -104,7 +111,7 @@ public class DrawGroupedGroupFolder : IDrawFolder
ImGui.TextUnformatted("All Syncshells");
ImGui.SameLine();
DrawPauseButton();
DrawPauseButton(hasPairs);
}
}
color.Dispose();
@@ -151,9 +158,9 @@ public class DrawGroupedGroupFolder : IDrawFolder
}
}
protected void DrawPauseButton()
protected void DrawPauseButton(bool hasPairs)
{
if (DrawPairs.Count > 0)
if (hasPairs)
{
var isPaused = _groups.Select(g => g.GroupFullInfo).All(g => g.GroupUserPermissions.IsPaused());
FontAwesomeIcon pauseIcon = isPaused ? FontAwesomeIcon.Play : FontAwesomeIcon.Pause;
@@ -179,6 +186,27 @@ public class DrawGroupedGroupFolder : IDrawFolder
}
}
private int CountOnlinePairs(IImmutableList<DrawUserPair> drawPairs)
{
if (drawPairs.Count == 0)
{
return 0;
}
_onlinePairBuffer.Clear();
foreach (var pair in drawPairs)
{
if (!pair.Pair.IsOnline)
{
continue;
}
_onlinePairBuffer.Add(pair.Pair.UserData.UID);
}
return _onlinePairBuffer.Count;
}
protected void ChangePauseStateGroups()
{
foreach(var group in _groups)

View File

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

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 (dlProg > 0)
{
fillPercent = transferredBytes / (double)totalBytes;
showFill = true;
}
else if (dlDecomp > 0 || dlComplete > 0 || transferredBytes >= totalBytes)
fillPercent = totalBytes > 0 ? transferredBytes / (double)totalBytes : 0.0;
if (isAllComplete && totalBytes > 0)
{
fillPercent = 1.0;
showFill = true;
}
showFill = transferredBytes > 0 || isAllComplete;
}
if (showFill)

View File

@@ -25,6 +25,7 @@ using LightlessSync.Services.LightFinder;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.PairProcessing;
using LightlessSync.Services.ServerConfiguration;
using LightlessSync.UI.Components;
using LightlessSync.UI.Models;
using LightlessSync.UI.Services;
using LightlessSync.UI.Style;
@@ -66,6 +67,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly PairUiService _pairUiService;
private readonly PerformanceCollectorService _performanceCollector;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly OptimizationSettingsPanel _optimizationSettingsPanel;
private readonly PairProcessingLimiter _pairProcessingLimiter;
private readonly EventAggregator _eventAggregator;
private readonly ServerConfigurationManager _serverConfigurationManager;
@@ -133,6 +135,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
private readonly Dictionary<string, double> _generalTreeHighlights = new(StringComparer.Ordinal);
private const float GeneralTreeHighlightDuration = 1.5f;
private readonly SeluneBrush _generalSeluneBrush = new();
private string? _performanceScrollTarget = null;
private string? _performanceOpenTreeTarget = null;
private const string PerformanceWarningsLabel = "Warnings";
private const string PerformanceAutoPauseLabel = "Auto Pause";
private const string PerformanceTextureOptimizationLabel = "Texture Optimization";
private const string PerformanceModelOptimizationLabel = "Model Optimization";
private static readonly (string Label, SeIconChar Icon)[] LightfinderIconPresets = new[]
{
@@ -208,6 +216,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
_httpClient = httpClient;
_fileCompactor = fileCompactor;
_uiShared = uiShared;
_optimizationSettingsPanel = new OptimizationSettingsPanel(_uiShared, _playerPerformanceConfigService, _pairUiService);
_nameplateService = nameplateService;
_actorObjectService = actorObjectService;
_validationProgress = new Progress<(int, int, FileCacheEntity)>(v => _currentProgress = v);
@@ -229,6 +238,11 @@ public class SettingsUi : WindowMediatorSubscriberBase
_selectGeneralTabOnNextDraw = true;
FocusGeneralTree("Lightfinder");
});
Mediator.Subscribe<OpenPerformanceSettingsMessage>(this, msg =>
{
IsOpen = true;
FocusPerformanceSection(msg.Section);
});
Mediator.Subscribe<SwitchToIntroUiMessage>(this, (_) => IsOpen = false);
Mediator.Subscribe<CutsceneStartMessage>(this, (_) => UiSharedService_GposeStart());
Mediator.Subscribe<CutsceneEndMessage>(this, (_) => UiSharedService_GposeEnd());
@@ -516,162 +530,6 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
private void DrawTextureDownscaleCounters()
{
HashSet<Pair> trackedPairs = new();
var snapshot = _pairUiService.GetSnapshot();
foreach (var pair in snapshot.DirectPairs)
{
trackedPairs.Add(pair);
}
foreach (var group in snapshot.GroupPairs.Values)
{
foreach (var pair in group)
{
trackedPairs.Add(pair);
}
}
long totalOriginalBytes = 0;
long totalEffectiveBytes = 0;
var hasData = false;
foreach (var pair in trackedPairs)
{
if (!pair.IsVisible)
continue;
var original = pair.LastAppliedApproximateVRAMBytes;
var effective = pair.LastAppliedApproximateEffectiveVRAMBytes;
if (original >= 0)
{
hasData = true;
totalOriginalBytes += original;
}
if (effective >= 0)
{
hasData = true;
totalEffectiveBytes += effective;
}
}
if (!hasData)
{
ImGui.TextDisabled("VRAM usage has not been calculated yet.");
return;
}
var savedBytes = Math.Max(0L, totalOriginalBytes - totalEffectiveBytes);
var originalText = UiSharedService.ByteToString(totalOriginalBytes, addSuffix: true);
var effectiveText = UiSharedService.ByteToString(totalEffectiveBytes, addSuffix: true);
var savedText = UiSharedService.ByteToString(savedBytes, addSuffix: true);
ImGui.TextUnformatted($"Total VRAM usage (original): {originalText}");
ImGui.TextUnformatted($"Total VRAM usage (effective): {effectiveText}");
if (savedBytes > 0)
{
UiSharedService.ColorText($"VRAM saved by downscaling: {savedText}", UIColors.Get("LightlessGreen"));
}
else
{
ImGui.TextUnformatted($"VRAM saved by downscaling: {savedText}");
}
}
private void DrawTriangleDecimationCounters()
{
HashSet<Pair> trackedPairs = new();
var snapshot = _pairUiService.GetSnapshot();
foreach (var pair in snapshot.DirectPairs)
{
trackedPairs.Add(pair);
}
foreach (var group in snapshot.GroupPairs.Values)
{
foreach (var pair in group)
{
trackedPairs.Add(pair);
}
}
long totalOriginalTris = 0;
long totalEffectiveTris = 0;
var hasData = false;
foreach (var pair in trackedPairs)
{
if (!pair.IsVisible)
continue;
var original = pair.LastAppliedDataTris;
var effective = pair.LastAppliedApproximateEffectiveTris;
if (original >= 0)
{
hasData = true;
totalOriginalTris += original;
}
if (effective >= 0)
{
hasData = true;
totalEffectiveTris += effective;
}
}
if (!hasData)
{
ImGui.TextDisabled("Triangle usage has not been calculated yet.");
return;
}
var savedTris = Math.Max(0L, totalOriginalTris - totalEffectiveTris);
var originalText = FormatTriangleCount(totalOriginalTris);
var effectiveText = FormatTriangleCount(totalEffectiveTris);
var savedText = FormatTriangleCount(savedTris);
ImGui.TextUnformatted($"Total triangle usage (original): {originalText}");
ImGui.TextUnformatted($"Total triangle usage (effective): {effectiveText}");
if (savedTris > 0)
{
UiSharedService.ColorText($"Triangles saved by decimation: {savedText}", UIColors.Get("LightlessGreen"));
}
else
{
ImGui.TextUnformatted($"Triangles saved by decimation: {savedText}");
}
static string FormatTriangleCount(long triangleCount)
{
if (triangleCount < 0)
{
return "n/a";
}
if (triangleCount >= 1_000_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000_000d:0.#}m tris");
}
if (triangleCount >= 1_000)
{
return FormattableString.Invariant($"{triangleCount / 1_000d:0.#}k tris");
}
return $"{triangleCount} tris";
}
}
private void DrawThemeVectorRow(MainStyle.StyleVector2Option option)
{
ImGui.TableNextRow();
@@ -1593,6 +1451,24 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.SameLine();
ImGui.TextColored(statusColor, $"[{(pair.IsVisible ? "Visible" : pair.IsOnline ? "Online" : "Offline")}]");
if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Pair Diagnostics##pairDebugCopy"))
{
ImGui.SetClipboardText(BuildPairDiagnosticsClipboard(pair, snapshot));
}
UiSharedService.AttachToolTip("Copies the current pair diagnostics to the clipboard.");
ImGui.SameLine();
if (_uiShared.IconTextButton(FontAwesomeIcon.Copy, "Copy Last Data JSON##pairDebugCopyLastData"))
{
var lastDataForClipboard = pair.LastReceivedCharacterData;
ImGui.SetClipboardText(lastDataForClipboard is null
? "ERROR: No character data has been received for this pair."
: JsonSerializer.Serialize(lastDataForClipboard, DebugJsonOptions));
}
UiSharedService.AttachToolTip("Copies the last received character data JSON to the clipboard.");
if (ImGui.BeginTable("##pairDebugProperties", 2, ImGuiTableFlags.SizingStretchProp))
{
DrawPairPropertyRow("UID", pair.UserData.UID);
@@ -1722,6 +1598,141 @@ public class SettingsUi : WindowMediatorSubscriberBase
DrawPairEventLog(pair);
}
private string BuildPairDiagnosticsClipboard(Pair pair, PairUiSnapshot snapshot)
{
var debugInfo = pair.GetDebugInfo();
StringBuilder sb = new();
sb.AppendLine("Lightless Pair Diagnostics");
sb.AppendLine($"Generated: {DateTime.Now.ToString("G", CultureInfo.CurrentCulture)}");
sb.AppendLine();
sb.AppendLine("Pair");
sb.AppendLine($"Alias/UID: {pair.UserData.AliasOrUID}");
sb.AppendLine($"UID: {pair.UserData.UID}");
sb.AppendLine($"Alias: {(string.IsNullOrEmpty(pair.UserData.Alias) ? "(none)" : pair.UserData.Alias)}");
sb.AppendLine($"Player Name: {pair.PlayerName ?? "(not cached)"}");
sb.AppendLine($"Handler Ident: {(string.IsNullOrEmpty(pair.Ident) ? "(not bound)" : pair.Ident)}");
sb.AppendLine($"Character Id: {FormatCharacterId(pair.PlayerCharacterId)}");
sb.AppendLine($"Direct Pair: {FormatBool(pair.IsDirectlyPaired)}");
sb.AppendLine($"Individual Status: {pair.IndividualPairStatus}");
sb.AppendLine($"Any Connection: {FormatBool(pair.HasAnyConnection())}");
sb.AppendLine($"Paused: {FormatBool(pair.IsPaused)}");
sb.AppendLine($"Visible: {FormatBool(pair.IsVisible)}");
sb.AppendLine($"Online: {FormatBool(pair.IsOnline)}");
sb.AppendLine($"Has Handler: {FormatBool(debugInfo.HasHandler)}");
sb.AppendLine($"Handler Initialized: {FormatBool(debugInfo.HandlerInitialized)}");
sb.AppendLine($"Handler Visible: {FormatBool(debugInfo.HandlerVisible)}");
sb.AppendLine($"Last Time person rendered in: {FormatTimestamp(debugInfo.InvisibleSinceUtc)}");
sb.AppendLine($"Handler Timer Temp Collection removal: {FormatCountdown(debugInfo.VisibilityEvictionRemainingSeconds)}");
sb.AppendLine($"Handler Scheduled For Deletion: {FormatBool(debugInfo.HandlerScheduledForDeletion)}");
sb.AppendLine($"Note: {pair.GetNote() ?? "(none)"}");
sb.AppendLine();
sb.AppendLine("Applied Data");
sb.AppendLine($"Last Data Size: {FormatBytes(pair.LastAppliedDataBytes)}");
sb.AppendLine($"Approx. VRAM: {FormatBytes(pair.LastAppliedApproximateVRAMBytes)}");
sb.AppendLine($"Effective VRAM: {FormatBytes(pair.LastAppliedApproximateEffectiveVRAMBytes)}");
sb.AppendLine($"Last Triangles: {(pair.LastAppliedDataTris < 0 ? "n/a" : pair.LastAppliedDataTris.ToString(CultureInfo.InvariantCulture))}");
sb.AppendLine($"Effective Triangles: {(pair.LastAppliedApproximateEffectiveTris < 0 ? "n/a" : pair.LastAppliedApproximateEffectiveTris.ToString(CultureInfo.InvariantCulture))}");
sb.AppendLine();
sb.AppendLine("Last Received Character Data");
var lastData = pair.LastReceivedCharacterData;
if (lastData is null)
{
sb.AppendLine("None");
}
else
{
var fileReplacementCount = lastData.FileReplacements.Values.Sum(list => list?.Count ?? 0);
var totalGamePaths = lastData.FileReplacements.Values.Sum(list => list?.Sum(replacement => replacement.GamePaths.Length) ?? 0);
sb.AppendLine($"File replacements: {fileReplacementCount} entries across {totalGamePaths} game paths.");
sb.AppendLine($"Customize+: {lastData.CustomizePlusData.Count}, Glamourer entries: {lastData.GlamourerData.Count}");
sb.AppendLine($"Manipulation length: {lastData.ManipulationData.Length}, Heels set: {FormatBool(!string.IsNullOrEmpty(lastData.HeelsData))}");
}
sb.AppendLine();
sb.AppendLine("Application Timeline");
sb.AppendLine($"Last Data Received: {FormatTimestamp(debugInfo.LastDataReceivedAt)}");
sb.AppendLine($"Last Apply Attempt: {FormatTimestamp(debugInfo.LastApplyAttemptAt)}");
sb.AppendLine($"Last Successful Apply: {FormatTimestamp(debugInfo.LastSuccessfulApplyAt)}");
if (!string.IsNullOrEmpty(debugInfo.LastFailureReason))
{
sb.AppendLine();
sb.AppendLine($"Last failure: {debugInfo.LastFailureReason}");
if (debugInfo.BlockingConditions.Count > 0)
{
sb.AppendLine("Blocking conditions:");
foreach (var condition in debugInfo.BlockingConditions)
{
sb.AppendLine($"- {condition}");
}
}
}
sb.AppendLine();
sb.AppendLine("Application & Download State");
sb.AppendLine($"Applying Data: {FormatBool(debugInfo.IsApplying)}");
sb.AppendLine($"Downloading: {FormatBool(debugInfo.IsDownloading)}");
sb.AppendLine($"Pending Downloads: {debugInfo.PendingDownloadCount.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine($"Forbidden Downloads: {debugInfo.ForbiddenDownloadCount.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine($"Pending Mod Reapply: {FormatBool(debugInfo.PendingModReapply)}");
sb.AppendLine($"Mod Apply Deferred: {FormatBool(debugInfo.ModApplyDeferred)}");
sb.AppendLine($"Missing Critical Mods: {debugInfo.MissingCriticalMods.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine($"Missing Non-Critical Mods: {debugInfo.MissingNonCriticalMods.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine($"Missing Forbidden Mods: {debugInfo.MissingForbiddenMods.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine();
sb.AppendLine("Syncshell Memberships");
if (snapshot.PairsWithGroups.TryGetValue(pair, out var groups) && groups.Count > 0)
{
foreach (var group in groups.OrderBy(g => g.Group.AliasOrGID, StringComparer.OrdinalIgnoreCase))
{
var flags = group.GroupPairUserInfos.TryGetValue(pair.UserData.UID, out var info) ? info : GroupPairUserInfo.None;
var flagLabel = flags switch
{
GroupPairUserInfo.None => string.Empty,
_ => $" ({string.Join(", ", GetGroupInfoFlags(flags))})"
};
sb.AppendLine($"{group.Group.AliasOrGID} [{group.Group.GID}]{flagLabel}");
}
}
else
{
sb.AppendLine("Not a member of any syncshells.");
}
sb.AppendLine();
sb.AppendLine("Pair DTO Snapshot");
if (pair.UserPair is null)
{
sb.AppendLine("(unavailable)");
}
else
{
sb.AppendLine(JsonSerializer.Serialize(pair.UserPair, DebugJsonOptions));
}
var relevantEvents = GetRelevantPairEvents(pair, 40);
sb.AppendLine();
sb.AppendLine("Recent Events");
if (relevantEvents.Count == 0)
{
sb.AppendLine("No recent events were logged for this pair.");
}
else
{
foreach (var ev in relevantEvents)
{
var timestamp = ev.EventTime.ToString("T", CultureInfo.CurrentCulture);
sb.AppendLine($"{timestamp} [{ev.EventSource}] {ev.EventSeverity}: {ev.Message}");
}
}
return sb.ToString();
}
private static IEnumerable<string> GetGroupInfoFlags(GroupPairUserInfo info)
{
if (info.HasFlag(GroupPairUserInfo.IsModerator))
@@ -1735,23 +1746,28 @@ public class SettingsUi : WindowMediatorSubscriberBase
}
}
private void DrawPairEventLog(Pair pair)
private List<Event> GetRelevantPairEvents(Pair pair, int maxEvents)
{
ImGui.TextUnformatted("Recent Events");
var events = _eventAggregator.EventList.Value;
var alias = pair.UserData.Alias;
var aliasOrUid = pair.UserData.AliasOrUID;
var rawUid = pair.UserData.UID;
var playerName = pair.PlayerName;
var relevantEvents = events.Where(e =>
return events.Where(e =>
EventMatchesIdentifier(e, rawUid)
|| EventMatchesIdentifier(e, aliasOrUid)
|| EventMatchesIdentifier(e, alias)
|| (!string.IsNullOrEmpty(playerName) && string.Equals(e.Character, playerName, StringComparison.OrdinalIgnoreCase)))
.OrderByDescending(e => e.EventTime)
.Take(40)
.Take(maxEvents)
.ToList();
}
private void DrawPairEventLog(Pair pair)
{
ImGui.TextUnformatted("Recent Events");
var relevantEvents = GetRelevantPairEvents(pair, 40);
if (relevantEvents.Count == 0)
{
@@ -2290,11 +2306,29 @@ public class SettingsUi : WindowMediatorSubscriberBase
var syncshellOfflineSeparate = _configService.Current.ShowSyncshellOfflineUsersSeparately;
var greenVisiblePair = _configService.Current.ShowVisiblePairsGreenEye;
var enableParticleEffects = _configService.Current.EnableParticleEffects;
var showUiWhenUiHidden = _configService.Current.ShowUiWhenUiHidden;
var showUiInGpose = _configService.Current.ShowUiInGpose;
using (var behaviorTree = BeginGeneralTree("Behavior", UIColors.Get("LightlessPurple")))
{
if (behaviorTree.Visible)
{
if (ImGui.Checkbox("Show Lightless windows when game UI is hidden", ref showUiWhenUiHidden))
{
_configService.Current.ShowUiWhenUiHidden = showUiWhenUiHidden;
_configService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden when the game UI is hidden.");
if (ImGui.Checkbox("Show Lightless windows in group pose", ref showUiInGpose))
{
_configService.Current.ShowUiInGpose = showUiInGpose;
_configService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless windows (except chat) are hidden while in group pose.");
if (ImGui.Checkbox("Enable Particle Effects", ref enableParticleEffects))
{
_configService.Current.EnableParticleEffects = enableParticleEffects;
@@ -3401,6 +3435,43 @@ public class SettingsUi : WindowMediatorSubscriberBase
_generalTreeHighlights[label] = ImGui.GetTime();
}
private void FocusPerformanceSection(PerformanceSettingsSection section)
{
_selectGeneralTabOnNextDraw = false;
_selectedMainTab = MainSettingsTab.Performance;
var label = section switch
{
PerformanceSettingsSection.TextureOptimization => PerformanceTextureOptimizationLabel,
PerformanceSettingsSection.ModelOptimization => PerformanceModelOptimizationLabel,
_ => PerformanceTextureOptimizationLabel,
};
_performanceOpenTreeTarget = label;
_performanceScrollTarget = label;
}
private bool BeginPerformanceTree(string label, Vector4 color)
{
var shouldForceOpen = string.Equals(_performanceOpenTreeTarget, label, StringComparison.Ordinal);
if (shouldForceOpen)
{
ImGui.SetNextItemOpen(true, ImGuiCond.Always);
}
var open = _uiShared.MediumTreeNode(label, color);
if (shouldForceOpen)
{
_performanceOpenTreeTarget = null;
}
if (open && string.Equals(_performanceScrollTarget, label, StringComparison.Ordinal))
{
ImGui.SetScrollHereY(0f);
_performanceScrollTarget = null;
}
return open;
}
private float GetGeneralTreeHighlightAlpha(string label)
{
if (!_generalTreeHighlights.TryGetValue(label, out var startTime))
@@ -3490,7 +3561,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool showPerformanceIndicator = _playerPerformanceConfigService.Current.ShowPerformanceIndicator;
if (_uiShared.MediumTreeNode("Warnings", UIColors.Get("LightlessPurple")))
if (BeginPerformanceTree(PerformanceWarningsLabel, UIColors.Get("LightlessPurple")))
{
if (ImGui.Checkbox("Show performance indicator", ref showPerformanceIndicator))
{
@@ -3586,7 +3657,7 @@ public class SettingsUi : WindowMediatorSubscriberBase
bool autoPauseInCombat = _playerPerformanceConfigService.Current.PauseInCombat;
bool autoPauseWhilePerforming = _playerPerformanceConfigService.Current.PauseWhilePerforming;
if (_uiShared.MediumTreeNode("Auto Pause", UIColors.Get("LightlessPurple")))
if (BeginPerformanceTree(PerformanceAutoPauseLabel, UIColors.Get("LightlessPurple")))
{
if (ImGui.Checkbox("Auto pause sync while combat", ref autoPauseInCombat))
{
@@ -3683,261 +3754,12 @@ public class SettingsUi : WindowMediatorSubscriberBase
ImGui.Separator();
if (_uiShared.MediumTreeNode("Texture Optimization", UIColors.Get("LightlessYellow")))
{
_uiShared.MediumText("Warning", UIColors.Get("DimRed"));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Texture compression and downscaling is potentially a "),
new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("This feature is encouraged to help "),
new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" and for use in "),
new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Runtime downscaling "),
new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true));
var textureConfig = _playerPerformanceConfigService.Current;
var trimNonIndex = textureConfig.EnableNonIndexTextureMipTrim;
if (ImGui.Checkbox("Trim mip levels for textures", ref trimNonIndex))
{
textureConfig.EnableNonIndexTextureMipTrim = trimNonIndex;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, Lightless will remove high-resolution mip levels from textures (not index) that exceed the size limit and are not compressed with any kind compression.");
var downscaleIndex = textureConfig.EnableIndexTextureDownscale;
if (ImGui.Checkbox("Downscale index textures above limit", ref downscaleIndex))
{
textureConfig.EnableIndexTextureDownscale = downscaleIndex;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("Controls whether Lightless reduces index textures that exceed the size limit.");
var dimensionOptions = new[] { 512, 1024, 2048, 4096 };
var optionLabels = dimensionOptions.Select(selector: static value => value.ToString()).ToArray();
var currentDimension = textureConfig.TextureDownscaleMaxDimension;
var selectedIndex = Array.IndexOf(dimensionOptions, currentDimension);
if (selectedIndex < 0)
{
selectedIndex = Array.IndexOf(dimensionOptions, 2048);
}
ImGui.SetNextItemWidth(140 * ImGuiHelpers.GlobalScale);
if (ImGui.Combo("Maximum texture dimension", ref selectedIndex, optionLabels, optionLabels.Length))
{
textureConfig.TextureDownscaleMaxDimension = dimensionOptions[selectedIndex];
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText($"Textures above this size will be reduced until their largest dimension is at or below the limit. Block-compressed textures are skipped when \"Only downscale uncompressed\" is enabled.{UiSharedService.TooltipSeparator}Default: 2048");
var keepOriginalTextures = textureConfig.KeepOriginalTextureFiles;
if (ImGui.Checkbox("Keep original texture files", ref keepOriginalTextures))
{
textureConfig.KeepOriginalTextureFiles = keepOriginalTextures;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless removes the original texture after a downscaled copy is created.");
ImGui.SameLine();
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective VRAM usage information will not work.", UIColors.Get("LightlessYellow")));
var skipPreferredDownscale = textureConfig.SkipTextureDownscaleForPreferredPairs;
if (ImGui.Checkbox("Skip downscale for preferred/direct pairs", ref skipPreferredDownscale))
{
textureConfig.SkipTextureDownscaleForPreferredPairs = skipPreferredDownscale;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, textures for direct pairs with preferred permissions are left untouched.");
if (!textureConfig.EnableNonIndexTextureMipTrim && !textureConfig.EnableIndexTextureDownscale)
{
UiSharedService.ColorTextWrapped("Both trimming and downscale are disabled. Lightless will keep original textures regardless of size.", UIColors.Get("DimRed"));
}
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f);
var onlyUncompressed = textureConfig.OnlyDownscaleUncompressedTextures;
if (ImGui.Checkbox("Only downscale uncompressed textures", ref onlyUncompressed))
{
textureConfig.OnlyDownscaleUncompressedTextures = onlyUncompressed;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("If disabled, compressed textures will be targeted for downscaling too.");
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 3f);
ImGui.Dummy(new Vector2(5));
DrawTextureDownscaleCounters();
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("LightlessYellow"), 1.5f);
ImGui.TreePop();
}
ImGui.Separator();
if (_uiShared.MediumTreeNode("Model Optimization", UIColors.Get("DimRed")))
{
_uiShared.MediumText("Warning", UIColors.Get("DimRed"));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Model decimation is a "),
new SeStringUtils.RichTextEntry("destructive", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" process and may cause broken or incorrect character appearances."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("This feature is encouraged to help "),
new SeStringUtils.RichTextEntry("lower-end systems with limited VRAM", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry(" and for use in "),
new SeStringUtils.RichTextEntry("performance-critical scenarios", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Runtime decimation "),
new SeStringUtils.RichTextEntry("MAY", UIColors.Get("DimRed"), true),
new SeStringUtils.RichTextEntry(" cause higher load on the system when processing downloads."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("When enabled, we cannot provide support for appearance issues caused by this setting!", UIColors.Get("DimRed"), true));
ImGui.Dummy(new Vector2(15));
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"),
new SeStringUtils.RichTextEntry("If a mesh exceeds the "),
new SeStringUtils.RichTextEntry("triangle threshold", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry(", it will be decimated automatically to the set "),
new SeStringUtils.RichTextEntry("target triangle ratio", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry(". This will reduce quality of the mesh or may break it's intended structure."));
var performanceConfig = _playerPerformanceConfigService.Current;
var enableDecimation = performanceConfig.EnableModelDecimation;
if (ImGui.Checkbox("Enable model decimation", ref enableDecimation))
{
performanceConfig.EnableModelDecimation = enableDecimation;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, Lightless generates a decimated copy of given model after download.");
var keepOriginalModels = performanceConfig.KeepOriginalModelFiles;
if (ImGui.Checkbox("Keep original model files", ref keepOriginalModels))
{
performanceConfig.KeepOriginalModelFiles = keepOriginalModels;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When disabled, Lightless removes the original model after a decimated copy is created.");
ImGui.SameLine();
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"), new SeStringUtils.RichTextEntry("If disabled, saved + effective triangle usage information will not work.", UIColors.Get("LightlessYellow")));
var skipPreferredDecimation = performanceConfig.SkipModelDecimationForPreferredPairs;
if (ImGui.Checkbox("Skip decimation for preferred/direct pairs", ref skipPreferredDecimation))
{
performanceConfig.SkipModelDecimationForPreferredPairs = skipPreferredDecimation;
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText("When enabled, models for direct pairs with preferred permissions are left untouched.");
var triangleThreshold = performanceConfig.ModelDecimationTriangleThreshold;
ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderInt("Decimate models above", ref triangleThreshold, 8_000, 100_000))
{
performanceConfig.ModelDecimationTriangleThreshold = Math.Clamp(triangleThreshold, 8_000, 100_000);
_playerPerformanceConfigService.Save();
}
ImGui.SameLine();
ImGui.Text("triangles");
_uiShared.DrawHelpText($"Models below this triangle count are left untouched.{UiSharedService.TooltipSeparator}Default: 50,000");
var targetPercent = (float)(performanceConfig.ModelDecimationTargetRatio * 100.0);
var clampedPercent = Math.Clamp(targetPercent, 60f, 99f);
if (Math.Abs(clampedPercent - targetPercent) > float.Epsilon)
{
performanceConfig.ModelDecimationTargetRatio = clampedPercent / 100.0;
_playerPerformanceConfigService.Save();
targetPercent = clampedPercent;
}
ImGui.SetNextItemWidth(300 * ImGuiHelpers.GlobalScale);
if (ImGui.SliderFloat("Target triangle ratio", ref targetPercent, 60f, 99f, "%.0f%%"))
{
performanceConfig.ModelDecimationTargetRatio = Math.Clamp(targetPercent / 100f, 0.6f, 0.99f);
_playerPerformanceConfigService.Save();
}
_uiShared.DrawHelpText($"Target ratio relative to original triangle count (80% keeps 80% of triangles).{UiSharedService.TooltipSeparator}Default: 80%");
ImGui.Dummy(new Vector2(15));
ImGui.TextUnformatted("Decimation targets");
_uiShared.DrawHelpText("Hair mods are always excluded from decimation.");
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessGreen"),
new SeStringUtils.RichTextEntry("Automatic decimation will only target the selected "),
new SeStringUtils.RichTextEntry("decimation targets", UIColors.Get("LightlessGreen"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("! ", UIColors.Get("LightlessYellow"),
new SeStringUtils.RichTextEntry("It is advised to not decimate any body related meshes which includes: "),
new SeStringUtils.RichTextEntry("facial mods + sculpts, chest, legs, hands and feet", UIColors.Get("LightlessYellow"), true),
new SeStringUtils.RichTextEntry("."));
_uiShared.DrawNoteLine("!!! ", UIColors.Get("DimRed"),
new SeStringUtils.RichTextEntry("Remember, automatic decimation is not perfect and can cause meshes to be ruined, especially hair mods.", UIColors.Get("DimRed"), true));
var allowBody = performanceConfig.ModelDecimationAllowBody;
if (ImGui.Checkbox("Body", ref allowBody))
{
performanceConfig.ModelDecimationAllowBody = allowBody;
_playerPerformanceConfigService.Save();
}
var allowFaceHead = performanceConfig.ModelDecimationAllowFaceHead;
if (ImGui.Checkbox("Face/head", ref allowFaceHead))
{
performanceConfig.ModelDecimationAllowFaceHead = allowFaceHead;
_playerPerformanceConfigService.Save();
}
var allowTail = performanceConfig.ModelDecimationAllowTail;
if (ImGui.Checkbox("Tails/Ears", ref allowTail))
{
performanceConfig.ModelDecimationAllowTail = allowTail;
_playerPerformanceConfigService.Save();
}
var allowClothing = performanceConfig.ModelDecimationAllowClothing;
if (ImGui.Checkbox("Clothing (body/legs/shoes/gloves/hats)", ref allowClothing))
{
performanceConfig.ModelDecimationAllowClothing = allowClothing;
_playerPerformanceConfigService.Save();
}
var allowAccessories = performanceConfig.ModelDecimationAllowAccessories;
if (ImGui.Checkbox("Accessories (earring/rings/bracelet/necklace)", ref allowAccessories))
{
performanceConfig.ModelDecimationAllowAccessories = allowAccessories;
_playerPerformanceConfigService.Save();
}
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("LightlessGrey"), 3f);
ImGui.Dummy(new Vector2(5));
DrawTriangleDecimationCounters();
ImGui.Dummy(new Vector2(5));
UiSharedService.ColoredSeparator(UIColors.Get("DimRed"), 1.5f);
ImGui.TreePop();
}
_optimizationSettingsPanel.DrawSettingsTrees(
PerformanceTextureOptimizationLabel,
UIColors.Get("LightlessYellow"),
PerformanceModelOptimizationLabel,
UIColors.Get("LightlessOrange"),
BeginPerformanceTree);
ImGui.Separator();
ImGui.Dummy(new Vector2(10));

View File

@@ -40,9 +40,9 @@ internal static class MainStyle
new("color.frameBg", "Frame Background", () => Rgba(40, 40, 40, 255), ImGuiCol.FrameBg),
new("color.frameBgHovered", "Frame Background (Hover)", () => Rgba(50, 50, 50, 100), ImGuiCol.FrameBgHovered),
new("color.frameBgActive", "Frame Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.FrameBgActive),
new("color.titleBg", "Title Background", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(22, 14, 41, 255), ImGuiCol.TitleBgCollapsed),
new("color.titleBg", "Title Background", () => Rgba(24, 24, 24, 232), ImGuiCol.TitleBg),
new("color.titleBgActive", "Title Background (Active)", () => Rgba(30, 30, 30, 255), ImGuiCol.TitleBgActive),
new("color.titleBgCollapsed", "Title Background (Collapsed)", () => Rgba(27, 27, 27, 255), ImGuiCol.TitleBgCollapsed),
new("color.menuBarBg", "Menu Bar Background", () => Rgba(36, 36, 36, 255), ImGuiCol.MenuBarBg),
new("color.scrollbarBg", "Scrollbar Background", () => Rgba(0, 0, 0, 0), ImGuiCol.ScrollbarBg),

View File

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

File diff suppressed because it is too large Load Diff

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",
"contentHash": "xfaHEHVDkMOOZR5S6ZGezD0+vekdH1Nx/9Ih8/rOqOGSOk1fxiN3u94bYkBW/wigj0Uw2Wt3vvRj9mtYdgwEjw=="
},
"lightlesscompactor": {
"type": "Project",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "[10.0.1, )"
}
},
"lightlesssync.api": {
"type": "Project",
"dependencies": {