using LightlessSync.LightlessConfiguration; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; 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 PlayerPerformanceConfigService _performanceConfigService; private readonly XivDataStorageService _xivDataStorageService; private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs); private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _decimatedPaths = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _failedHashes = new(StringComparer.OrdinalIgnoreCase); public ModelDecimationService( ILogger logger, LightlessConfigService configService, PlayerPerformanceConfigService performanceConfigService, XivDataStorageService xivDataStorageService) { _logger = logger; _configService = configService; _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? 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 (_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)) { _decimatedPaths[hash] = 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; } _decimatedPaths[hash] = destination; _logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination); return Task.CompletedTask; } 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; } }