Merged Cake and Abel branched into 2.0.3 (#131)

Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #131
This commit was merged in pull request #131.
This commit is contained in:
2026-01-05 00:45:14 +00:00
parent e0b8070aa8
commit 30717ba200
67 changed files with 13247 additions and 802 deletions

View File

@@ -0,0 +1,381 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
namespace LightlessSync.Services.ModelDecimation;
public sealed class ModelDecimationService
{
private const int MaxConcurrentJobs = 1;
private const double MinTargetRatio = 0.01;
private const double MaxTargetRatio = 0.99;
private readonly ILogger<ModelDecimationService> _logger;
private readonly LightlessConfigService _configService;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
public ModelDecimationService(
ILogger<ModelDecimationService> logger,
LightlessConfigService configService,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService performanceConfigService,
XivDataStorageService xivDataStorageService)
{
_logger = logger;
_configService = configService;
_fileCacheManager = fileCacheManager;
_performanceConfigService = performanceConfigService;
_xivDataStorageService = xivDataStorageService;
}
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
{
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
{
return;
}
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash))
{
return;
}
_logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () =>
{
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
}
catch (Exception ex)
{
_failedHashes[hash] = 1;
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
}
finally
{
_decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}, CancellationToken.None);
}
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
=> IsDecimationEnabled()
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
&& IsDecimationAllowed(gamePath)
&& !ShouldSkipByTriangleCache(hash);
public string GetPreferredPath(string hash, string originalPath)
{
if (!IsDecimationEnabled())
{
return originalPath;
}
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDecimatedPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_decimatedPaths[hash] = resolved;
return resolved;
}
return originalPath;
}
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
{
if (hashes is null)
{
return Task.CompletedTask;
}
var pending = new List<Task>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hash in hashes)
{
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
{
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
{
pending.Add(job);
}
}
if (pending.Count == 0)
{
return Task.CompletedTask;
}
return Task.WhenAll(pending).WaitAsync(token);
}
private Task DecimateInternalAsync(string hash, string sourcePath)
{
if (!File.Exists(sourcePath))
{
_failedHashes[hash] = 1;
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.CompletedTask;
}
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio))
{
_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);
var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination))
{
RegisterDecimatedModel(hash, sourcePath, destination);
return Task.CompletedTask;
}
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger))
{
_failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash);
return Task.CompletedTask;
}
RegisterDecimatedModel(hash, sourcePath, destination);
_logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination);
return Task.CompletedTask;
}
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
{
_decimatedPaths[hash] = destination;
var performanceConfig = _performanceConfigService.Current;
if (performanceConfig.KeepOriginalModelFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
return false;
}
}
private bool IsDecimationEnabled()
=> _performanceConfigService.Current.EnableModelDecimation;
private bool ShouldSkipByTriangleCache(string hash)
{
if (string.IsNullOrEmpty(hash))
{
return false;
}
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
{
return false;
}
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
return threshold > 0 && cachedTris < threshold;
}
private bool IsDecimationAllowed(string? gamePath)
{
if (string.IsNullOrWhiteSpace(gamePath))
{
return true;
}
var normalized = NormalizeGamePath(gamePath);
if (normalized.Contains("/hair/", StringComparison.Ordinal))
{
return false;
}
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowClothing;
}
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
}
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
{
if (normalized.Contains("/body/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowBody;
}
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
}
if (normalized.Contains("/tail/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowTail;
}
}
return true;
}
private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant();
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio)
{
triangleThreshold = 15_000;
targetRatio = 0.8;
var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation)
{
return false;
}
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{
return false;
}
targetRatio = Math.Clamp(targetRatio, MinTargetRatio, MaxTargetRatio);
return true;
}
private string? GetExistingDecimatedPath(string hash)
{
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
return File.Exists(candidate) ? candidate : null;
}
private string GetDecimatedDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path))
{
return;
}
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}