using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Configurations; 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.LogDebug("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 void ScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings) { if (!ShouldScheduleBatchDecimation(hash, filePath, settings)) { return; } if (_decimationDeduplicator.TryGetExisting(hash, out _)) { return; } _failedHashes.TryRemove(hash, out _); _decimatedPaths.TryRemove(hash, out _); _logger.LogInformation("Queued batch model decimation for {Hash}", hash); _decimationDeduplicator.GetOrStart(hash, async () => { await _decimationSemaphore.WaitAsync().ConfigureAwait(false); try { await DecimateInternalAsync(hash, filePath, settings, allowExisting: false, destinationOverride: filePath, registerDecimatedPath: false).ConfigureAwait(false); } catch (Exception ex) { _failedHashes[hash] = 1; _logger.LogWarning(ex, "Batch model decimation failed for {Hash}", hash); } finally { _decimationSemaphore.Release(); } }); } public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null) { var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold); return IsDecimationEnabled() && filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase) && IsDecimationAllowed(gamePath) && !ShouldSkipByTriangleCache(hash, threshold); } 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 (!TryGetDecimationSettings(out var settings)) { _logger.LogDebug("Model decimation disabled or invalid settings for {Hash}", hash); return Task.CompletedTask; } return DecimateInternalAsync(hash, sourcePath, settings, allowExisting: true); } private Task DecimateInternalAsync( string hash, string sourcePath, ModelDecimationSettings settings, bool allowExisting, string? destinationOverride = null, bool registerDecimatedPath = true) { if (!File.Exists(sourcePath)) { _failedHashes[hash] = 1; _logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath); return Task.CompletedTask; } if (!TryNormalizeSettings(settings, out var normalized)) { _logger.LogDebug("Model decimation skipped for {Hash}; invalid settings.", hash); return Task.CompletedTask; } _logger.LogDebug( "Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents}, avoid body intersection {AvoidBodyIntersection})", hash, normalized.TriangleThreshold, normalized.TargetRatio, normalized.NormalizeTangents, normalized.AvoidBodyIntersection); var destination = destinationOverride ?? Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl"); var inPlace = string.Equals(destination, sourcePath, StringComparison.OrdinalIgnoreCase); if (!inPlace && File.Exists(destination)) { if (allowExisting) { if (registerDecimatedPath) { RegisterDecimatedModel(hash, sourcePath, destination); } return Task.CompletedTask; } TryDelete(destination); } if (!MdlDecimator.TryDecimate(sourcePath, destination, normalized, _logger)) { _failedHashes[hash] = 1; _logger.LogDebug("Model decimation skipped for {Hash}", hash); return Task.CompletedTask; } if (registerDecimatedPath) { RegisterDecimatedModel(hash, sourcePath, destination); } _logger.LogDebug("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, int triangleThreshold) { if (string.IsNullOrEmpty(hash)) { return false; } if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0) { return false; } var threshold = Math.Max(0, triangleThreshold); return threshold > 0 && cachedTris < threshold; } private bool IsDecimationAllowed(string? gamePath) { if (string.IsNullOrWhiteSpace(gamePath)) { return true; } var normalized = ModelDecimationFilters.NormalizePath(gamePath); if (ModelDecimationFilters.IsHairPath(normalized)) { return false; } if (ModelDecimationFilters.IsClothingPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowClothing; } if (ModelDecimationFilters.IsAccessoryPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowAccessories; } if (ModelDecimationFilters.IsBodyPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowBody; } if (ModelDecimationFilters.IsFaceHeadPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowFaceHead; } if (ModelDecimationFilters.IsTailOrEarPath(normalized)) { return _performanceConfigService.Current.ModelDecimationAllowTail; } return true; } private bool TryGetDecimationSettings(out ModelDecimationSettings settings) { settings = new ModelDecimationSettings( ModelDecimationDefaults.TriangleThreshold, ModelDecimationDefaults.TargetRatio, ModelDecimationDefaults.NormalizeTangents, ModelDecimationDefaults.AvoidBodyIntersection, new ModelDecimationAdvancedSettings()); var config = _performanceConfigService.Current; if (!config.EnableModelDecimation) { return false; } var advanced = NormalizeAdvancedSettings(config.ModelDecimationAdvanced); settings = new ModelDecimationSettings( Math.Max(0, config.ModelDecimationTriangleThreshold), config.ModelDecimationTargetRatio, config.ModelDecimationNormalizeTangents, config.ModelDecimationAvoidBodyIntersection, advanced); return TryNormalizeSettings(settings, out settings); } private static bool TryNormalizeSettings(ModelDecimationSettings settings, out ModelDecimationSettings normalized) { var ratio = settings.TargetRatio; if (double.IsNaN(ratio) || double.IsInfinity(ratio)) { normalized = default; return false; } ratio = Math.Clamp(ratio, MinTargetRatio, MaxTargetRatio); var advanced = NormalizeAdvancedSettings(settings.Advanced); normalized = new ModelDecimationSettings( Math.Max(0, settings.TriangleThreshold), ratio, settings.NormalizeTangents, settings.AvoidBodyIntersection, advanced); return true; } private static ModelDecimationAdvancedSettings NormalizeAdvancedSettings(ModelDecimationAdvancedSettings? settings) { var source = settings ?? new ModelDecimationAdvancedSettings(); return new ModelDecimationAdvancedSettings { MinComponentTriangles = Math.Clamp(source.MinComponentTriangles, 0, 1000), MaxCollapseEdgeLengthFactor = ClampFloat(source.MaxCollapseEdgeLengthFactor, 0.1f, 10f, ModelDecimationAdvancedSettings.DefaultMaxCollapseEdgeLengthFactor), NormalSimilarityThresholdDegrees = ClampFloat(source.NormalSimilarityThresholdDegrees, 0f, 180f, ModelDecimationAdvancedSettings.DefaultNormalSimilarityThresholdDegrees), BoneWeightSimilarityThreshold = ClampFloat(source.BoneWeightSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBoneWeightSimilarityThreshold), UvSimilarityThreshold = ClampFloat(source.UvSimilarityThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultUvSimilarityThreshold), UvSeamAngleCos = ClampFloat(source.UvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultUvSeamAngleCos), BlockUvSeamVertices = source.BlockUvSeamVertices, AllowBoundaryCollapses = source.AllowBoundaryCollapses, BodyCollisionDistanceFactor = ClampFloat(source.BodyCollisionDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionDistanceFactor), BodyCollisionNoOpDistanceFactor = ClampFloat(source.BodyCollisionNoOpDistanceFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpDistanceFactor), BodyCollisionAdaptiveRelaxFactor = ClampFloat(source.BodyCollisionAdaptiveRelaxFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveRelaxFactor), BodyCollisionAdaptiveNearRatio = ClampFloat(source.BodyCollisionAdaptiveNearRatio, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveNearRatio), BodyCollisionAdaptiveUvThreshold = ClampFloat(source.BodyCollisionAdaptiveUvThreshold, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionAdaptiveUvThreshold), BodyCollisionNoOpUvSeamAngleCos = ClampFloat(source.BodyCollisionNoOpUvSeamAngleCos, -1f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionNoOpUvSeamAngleCos), BodyCollisionProtectionFactor = ClampFloat(source.BodyCollisionProtectionFactor, 0f, 10f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProtectionFactor), BodyProxyTargetRatioMin = ClampFloat(source.BodyProxyTargetRatioMin, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyProxyTargetRatioMin), BodyCollisionProxyInflate = ClampFloat(source.BodyCollisionProxyInflate, 0f, 0.1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionProxyInflate), BodyCollisionPenetrationFactor = ClampFloat(source.BodyCollisionPenetrationFactor, 0f, 1f, ModelDecimationAdvancedSettings.DefaultBodyCollisionPenetrationFactor), MinBodyCollisionDistance = ClampFloat(source.MinBodyCollisionDistance, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionDistance), MinBodyCollisionCellSize = ClampFloat(source.MinBodyCollisionCellSize, 1e-6f, 1f, ModelDecimationAdvancedSettings.DefaultMinBodyCollisionCellSize), }; } private static float ClampFloat(float value, float min, float max, float fallback) { if (float.IsNaN(value) || float.IsInfinity(value)) { return fallback; } return Math.Clamp(value, min, max); } private bool ShouldScheduleBatchDecimation(string hash, string filePath, ModelDecimationSettings settings) { if (string.IsNullOrWhiteSpace(filePath) || !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)) { return false; } if (!TryNormalizeSettings(settings, out _)) { return false; } 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 } } }