387 lines
12 KiB
C#
387 lines
12 KiB
C#
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<TextureCompressionService> _logger;
|
|
private readonly IpcManager _ipcManager;
|
|
private readonly FileCacheManager _fileCacheManager;
|
|
|
|
public IReadOnlyList<TextureCompressionTarget> SelectableTargets => TextureCompressionCapabilities.SelectableTargets;
|
|
public TextureCompressionTarget DefaultTarget => TextureCompressionCapabilities.DefaultTarget;
|
|
|
|
public TextureCompressionService(
|
|
ILogger<TextureCompressionService> logger,
|
|
IpcManager ipcManager,
|
|
FileCacheManager fileCacheManager)
|
|
{
|
|
_logger = logger;
|
|
_ipcManager = ipcManager;
|
|
_fileCacheManager = fileCacheManager;
|
|
}
|
|
|
|
public async Task ConvertTexturesAsync(
|
|
IReadOnlyList<TextureCompressionRequest> requests,
|
|
IProgress<TextureConversionProgress>? 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<TextureConversionProgress>? 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<string>(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<string>? 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;
|
|
}
|
|
}
|
|
}
|