using LightlessSync.Interop.Ipc; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; using System.Globalization; namespace LightlessSync.Services.TextureCompression; public sealed class TextureCompressionService { private readonly ILogger _logger; private readonly IpcManager _ipcManager; private readonly FileCacheManager _fileCacheManager; public IReadOnlyList SelectableTargets => TextureCompressionCapabilities.SelectableTargets; public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget; public TextureCompressionService( ILogger logger, IpcManager ipcManager, FileCacheManager fileCacheManager) { _logger = logger; _ipcManager = ipcManager; _fileCacheManager = fileCacheManager; } public async Task ConvertTexturesAsync( IReadOnlyList requests, IProgress? progress, CancellationToken token, bool requestRedraw = true, bool includeMipMaps = true) { if (requests.Count == 0) { return; } var total = requests.Count; var completed = 0; foreach (var request in requests) { token.ThrowIfCancellationRequested(); if (!TextureCompressionCapabilities.TryGetPenumbraTarget(request.Target, request.PrimaryFilePath, out var textureType)) { _logger.LogWarning("Unsupported compression target {Target} requested.", request.Target); completed++; continue; } await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false); completed++; } } public bool IsTargetSelectable(TextureCompressionTarget target) => TextureCompressionCapabilities.IsSelectable(target); public TextureCompressionTarget NormalizeTarget(TextureCompressionTarget? desired) => TextureCompressionCapabilities.Normalize(desired); private async Task RunPenumbraConversionAsync( TextureCompressionRequest request, TextureType targetType, int total, int completedBefore, IProgress? progress, CancellationToken token, bool requestRedraw, bool includeMipMaps) { var primaryPath = request.PrimaryFilePath; var displayJob = new TextureConversionJob( primaryPath, primaryPath, targetType, IncludeMipMaps: includeMipMaps, request.DuplicateFilePaths); var backupPath = CreateBackupCopy(primaryPath); var conversionJob = displayJob with { InputFile = backupPath }; progress?.Report(new TextureConversionProgress(completedBefore, total, displayJob)); try { WaitForAccess(primaryPath); await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false); if (!IsValidConversionResult(displayJob.OutputFile)) { throw new InvalidOperationException($"Penumbra conversion produced no output for {displayJob.OutputFile}."); } UpdateFileCache(displayJob); progress?.Report(new TextureConversionProgress(completedBefore + 1, total, displayJob)); } catch (Exception ex) { RestoreFromBackup(backupPath, displayJob.OutputFile, displayJob.DuplicateTargets, ex); throw; } finally { CleanupBackup(backupPath); } } private void UpdateFileCache(TextureConversionJob job) { var paths = new HashSet(StringComparer.OrdinalIgnoreCase) { job.OutputFile }; if (job.DuplicateTargets is { Count: > 0 }) { foreach (var duplicate in job.DuplicateTargets) { paths.Add(duplicate); } } if (paths.Count == 0) { return; } 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) { 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); } } } 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"); private static string CreateBackupCopy(string filePath) { if (!File.Exists(filePath)) { throw new FileNotFoundException($"Cannot back up missing texture file {filePath}.", filePath); } Directory.CreateDirectory(WorkingDirectory); var extension = Path.GetExtension(filePath); if (string.IsNullOrEmpty(extension)) { extension = ".tmp"; } var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); var backupName = $"{fileNameWithoutExtension}.backup.{Guid.NewGuid():N}{extension}"; var backupPath = Path.Combine(WorkingDirectory, backupName); WaitForAccess(filePath); File.Copy(filePath, backupPath, overwrite: false); return backupPath; } private const int MaxAccessRetries = 10; private static readonly TimeSpan AccessRetryDelay = TimeSpan.FromMilliseconds(200); private static void WaitForAccess(string filePath) { if (!File.Exists(filePath)) { return; } try { File.SetAttributes(filePath, FileAttributes.Normal); } catch { // ignore attribute changes here } Exception? lastException = null; for (var attempt = 0; attempt < MaxAccessRetries; attempt++) { try { using var stream = new FileStream( filePath, FileMode.Open, FileAccess.Read, FileShare.None); return; } catch (IOException ex) when (IsSharingViolation(ex)) { lastException = ex; } Thread.Sleep(AccessRetryDelay); } if (lastException != null) { throw lastException; } } private static bool IsSharingViolation(IOException ex) => ex.HResult == unchecked((int)0x80070020); private void RestoreFromBackup( string backupPath, string destinationPath, IReadOnlyList? duplicateTargets, Exception reason) { if (string.IsNullOrEmpty(backupPath)) { _logger.LogWarning(reason, "Conversion failed for {File}, but no backup was available to restore.", destinationPath); return; } if (!File.Exists(backupPath)) { _logger.LogWarning(reason, "Conversion failed for {File}, but backup path {Backup} no longer exists.", destinationPath, backupPath); return; } try { TryReplaceFile(backupPath, destinationPath); } catch (Exception restoreEx) { _logger.LogError(restoreEx, "Failed to restore texture {File} after conversion failure.", destinationPath); return; } if (duplicateTargets is { Count: > 0 }) { foreach (var duplicate in duplicateTargets) { if (string.Equals(destinationPath, duplicate, StringComparison.OrdinalIgnoreCase)) { continue; } try { File.Copy(destinationPath, duplicate, overwrite: true); } catch (Exception duplicateEx) { _logger.LogDebug(duplicateEx, "Failed to restore duplicate {Duplicate} after conversion failure.", duplicate); } } } _logger.LogWarning(reason, "Restored original texture {File} after conversion failure.", destinationPath); } private static void TryReplaceFile(string sourcePath, string destinationPath) { WaitForAccess(destinationPath); var destinationDirectory = Path.GetDirectoryName(destinationPath); if (!string.IsNullOrEmpty(destinationDirectory)) { Directory.CreateDirectory(destinationDirectory); } File.Copy(sourcePath, destinationPath, overwrite: true); } private static void CleanupBackup(string backupPath) { if (string.IsNullOrEmpty(backupPath)) { return; } try { if (File.Exists(backupPath)) { File.Delete(backupPath); } } catch { // avoid killing successful conversions on cleanup failure } } private static bool IsValidConversionResult(string path) { try { var fileInfo = new FileInfo(path); return fileInfo.Exists && fileInfo.Length > 0; } catch { return false; } } }