Files
LightlessClient/LightlessSync/Services/TextureCompression/TextureCompressionService.cs
2026-01-16 11:00:58 +09:00

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;
}
}
}