using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.Utils; 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 _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 TaskRegistry _decimationDeduplicator = new(); private readonly ConcurrentDictionary _decimatedPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _failedHashes = new(StringComparer.OrdinalIgnoreCase); public ModelDecimationService( ILogger 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) || _decimationDeduplicator.TryGetExisting(hash, out _)) { return; } _logger.LogInformation("Queued model decimation for {Hash}", hash); _decimationDeduplicator.GetOrStart(hash, 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(); } }); } 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? hashes, CancellationToken token) { if (hashes is null) { return Task.CompletedTask; } var pending = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var hash in hashes) { if (string.IsNullOrEmpty(hash) || !seen.Add(hash)) { continue; } if (_decimationDeduplicator.TryGetExisting(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, 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.##}, normalize tangents {NormalizeTangents})", hash, triangleThreshold, targetRatio, normalizeTangents); 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, normalizeTangents, _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, out bool normalizeTangents) { triangleThreshold = 15_000; targetRatio = 0.8; normalizeTangents = true; var config = _performanceConfigService.Current; if (!config.EnableModelDecimation) { return false; } triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold); targetRatio = config.ModelDecimationTargetRatio; normalizeTangents = config.ModelDecimationNormalizeTangents; 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 } } }