using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using LightlessSync.Interop.Ipc; using LightlessSync.FileCache; using Microsoft.Extensions.Logging; using Penumbra.Api.Enums; 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) { 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).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) { var primaryPath = request.PrimaryFilePath; var displayJob = new TextureConversionJob( primaryPath, primaryPath, targetType, IncludeMipMaps: true, 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).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) { if (!cacheEntries.TryGetValue(path, out var entry) || entry is null) { entry = _fileCacheManager.CreateFileEntry(path); if (entry is null) { _logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path); continue; } } try { _fileCacheManager.UpdateHashedFile(entry); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to refresh file cache entry for {Path}", path); } } } 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; } } }