715 lines
25 KiB
C#
715 lines
25 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Buffers;
|
|
using System.Buffers.Binary;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using OtterTex;
|
|
using OtterImage = OtterTex.Image;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.FileCache;
|
|
using Microsoft.Extensions.Logging;
|
|
using Lumina.Data.Files;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
/*
|
|
* OtterTex made by Ottermandias
|
|
* thank you!!
|
|
*/
|
|
|
|
namespace LightlessSync.Services.TextureCompression;
|
|
|
|
public sealed class TextureDownscaleService
|
|
{
|
|
private const int DefaultTargetMaxDimension = 2048;
|
|
private const int MaxSupportedTargetDimension = 8192;
|
|
private const int BlockMultiple = 4;
|
|
|
|
private readonly ILogger<TextureDownscaleService> _logger;
|
|
private readonly LightlessConfigService _configService;
|
|
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
|
|
private readonly FileCacheManager _fileCacheManager;
|
|
|
|
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
|
|
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
|
|
new Dictionary<int, TextureCompressionTarget>
|
|
{
|
|
[70] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_TYPELESS
|
|
[71] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM
|
|
[72] = TextureCompressionTarget.BC1, // DXGI_FORMAT_BC1_UNORM_SRGB
|
|
|
|
[73] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_TYPELESS
|
|
[74] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM
|
|
[75] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC2_UNORM_SRGB
|
|
[76] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_TYPELESS
|
|
[77] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM
|
|
[78] = TextureCompressionTarget.BC3, // DXGI_FORMAT_BC3_UNORM_SRGB
|
|
|
|
[79] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_TYPELESS
|
|
[80] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_UNORM
|
|
[81] = TextureCompressionTarget.BC4, // DXGI_FORMAT_BC4_SNORM
|
|
|
|
[82] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_TYPELESS
|
|
[83] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_UNORM
|
|
[84] = TextureCompressionTarget.BC5, // DXGI_FORMAT_BC5_SNORM
|
|
|
|
[94] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_TYPELESS (treated as BC7 for block detection)
|
|
[95] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_UF16
|
|
[96] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC6H_SF16
|
|
[97] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_TYPELESS
|
|
[98] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM
|
|
[99] = TextureCompressionTarget.BC7, // DXGI_FORMAT_BC7_UNORM_SRGB
|
|
};
|
|
|
|
public TextureDownscaleService(
|
|
ILogger<TextureDownscaleService> logger,
|
|
LightlessConfigService configService,
|
|
PlayerPerformanceConfigService playerPerformanceConfigService,
|
|
FileCacheManager fileCacheManager)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_playerPerformanceConfigService = playerPerformanceConfigService;
|
|
_fileCacheManager = fileCacheManager;
|
|
}
|
|
|
|
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
|
|
{
|
|
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
|
|
if (_activeJobs.ContainsKey(hash)) return;
|
|
|
|
_activeJobs[hash] = Task.Run(async () =>
|
|
{
|
|
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
|
|
}, CancellationToken.None);
|
|
}
|
|
|
|
public string GetPreferredPath(string hash, string originalPath)
|
|
{
|
|
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
var resolved = GetExistingDownscaledPath(hash);
|
|
if (!string.IsNullOrEmpty(resolved))
|
|
{
|
|
_downscaledPaths[hash] = resolved;
|
|
return resolved;
|
|
}
|
|
|
|
return originalPath;
|
|
}
|
|
|
|
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
|
|
{
|
|
if (hashes is null)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
var pending = new List<Task>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var hash in hashes)
|
|
{
|
|
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (_activeJobs.TryGetValue(hash, out var job))
|
|
{
|
|
pending.Add(job);
|
|
}
|
|
}
|
|
|
|
if (pending.Count == 0)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
return Task.WhenAll(pending).WaitAsync(token);
|
|
}
|
|
|
|
private async Task DownscaleInternalAsync(string hash, string sourcePath, TextureMapKind mapKind)
|
|
{
|
|
TexHeaderInfo? headerInfo = null;
|
|
string? destination = null;
|
|
int targetMaxDimension = 0;
|
|
bool onlyDownscaleUncompressed = false;
|
|
bool? isIndexTexture = null;
|
|
|
|
await _downscaleSemaphore.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
_logger.LogWarning("Cannot downscale texture {Hash}; source path missing: {Path}", hash, sourcePath);
|
|
return;
|
|
}
|
|
|
|
headerInfo = TryReadTexHeader(sourcePath, out var header)
|
|
? header
|
|
: (TexHeaderInfo?)null;
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
targetMaxDimension = ResolveTargetMaxDimension();
|
|
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
|
|
|
|
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
|
|
if (File.Exists(destination))
|
|
{
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
return;
|
|
}
|
|
|
|
var indexTexture = IsIndexMap(mapKind);
|
|
isIndexTexture = indexTexture;
|
|
if (!indexTexture)
|
|
{
|
|
if (performanceConfig.EnableNonIndexTextureMipTrim
|
|
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!performanceConfig.EnableNonIndexTextureMipTrim)
|
|
{
|
|
_logger.LogTrace("Skipping mip trim for non-index texture {Hash}; feature disabled.", hash);
|
|
}
|
|
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
|
|
return;
|
|
}
|
|
|
|
if (!performanceConfig.EnableIndexTextureDownscale)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
|
|
return;
|
|
}
|
|
|
|
if (headerInfo is { } headerValue &&
|
|
headerValue.Width <= targetMaxDimension &&
|
|
headerValue.Height <= targetMaxDimension)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
|
|
return;
|
|
}
|
|
|
|
if (onlyDownscaleUncompressed && headerInfo.HasValue && IsBlockCompressedFormat(headerInfo.Value.Format))
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
|
|
return;
|
|
}
|
|
|
|
using var sourceScratch = TexFileHelper.Load(sourcePath);
|
|
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
|
|
|
|
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
|
|
var width = rgbaInfo.Meta.Width;
|
|
var height = rgbaInfo.Meta.Height;
|
|
var requiredLength = width * height * bytesPerPixel;
|
|
|
|
var rgbaPixels = rgbaScratch.Pixels.Slice(0, requiredLength);
|
|
using var originalImage = SixLabors.ImageSharp.Image.LoadPixelData<Rgba32>(rgbaPixels, width, height);
|
|
|
|
var targetSize = CalculateTargetSize(originalImage.Width, originalImage.Height, targetMaxDimension);
|
|
if (targetSize.width == originalImage.Width && targetSize.height == originalImage.Height)
|
|
{
|
|
_downscaledPaths[hash] = sourcePath;
|
|
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
|
|
return;
|
|
}
|
|
|
|
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
|
|
|
|
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
|
|
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
|
|
|
|
TexFileHelper.Save(destination, finalScratch);
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
TryDelete(destination);
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Texture downscale failed for {Hash} ({MapKind}) from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, IsIndex={IsIndexTexture}, HeaderFormat={HeaderFormat}",
|
|
hash,
|
|
mapKind,
|
|
sourcePath,
|
|
destination ?? "<unresolved>",
|
|
targetMaxDimension,
|
|
onlyDownscaleUncompressed,
|
|
isIndexTexture,
|
|
headerInfo?.Format);
|
|
}
|
|
finally
|
|
{
|
|
_downscaleSemaphore.Release();
|
|
_activeJobs.TryRemove(hash, out _);
|
|
}
|
|
}
|
|
|
|
private static (int width, int height) CalculateTargetSize(int width, int height, int targetMaxDimension)
|
|
{
|
|
var resultWidth = width;
|
|
var resultHeight = height;
|
|
|
|
while (Math.Max(resultWidth, resultHeight) > targetMaxDimension)
|
|
{
|
|
resultWidth = Math.Max(BlockMultiple, resultWidth / 2);
|
|
resultHeight = Math.Max(BlockMultiple, resultHeight / 2);
|
|
}
|
|
|
|
return (resultWidth, resultHeight);
|
|
}
|
|
|
|
private static ScratchImage CreateScratchImage(Image<Rgba32> image, int width, int height)
|
|
{
|
|
const int BytesPerPixel = 4;
|
|
var requiredLength = width * height * BytesPerPixel;
|
|
|
|
static ScratchImage Create(ReadOnlySpan<byte> pixels, int width, int height)
|
|
{
|
|
var scratchResult = ScratchImage.FromRGBA(pixels, width, height, out var creationInfo);
|
|
return scratchResult.ThrowIfError(creationInfo);
|
|
}
|
|
|
|
if (image.DangerousTryGetSinglePixelMemory(out var pixelMemory))
|
|
{
|
|
var byteSpan = MemoryMarshal.AsBytes(pixelMemory.Span);
|
|
if (byteSpan.Length < requiredLength)
|
|
{
|
|
throw new InvalidOperationException($"Image buffer shorter than expected ({byteSpan.Length} < {requiredLength}).");
|
|
}
|
|
|
|
return Create(byteSpan.Slice(0, requiredLength), width, height);
|
|
}
|
|
|
|
var rented = ArrayPool<byte>.Shared.Rent(requiredLength);
|
|
try
|
|
{
|
|
var rentedSpan = rented.AsSpan(0, requiredLength);
|
|
image.CopyPixelDataTo(rentedSpan);
|
|
return Create(rentedSpan, width, height);
|
|
}
|
|
finally
|
|
{
|
|
ArrayPool<byte>.Shared.Return(rented);
|
|
}
|
|
}
|
|
|
|
private static bool IsIndexMap(TextureMapKind kind)
|
|
=> kind is TextureMapKind.Mask
|
|
or TextureMapKind.Index;
|
|
|
|
private Task<bool> TryDropTopMipAsync(
|
|
string hash,
|
|
string sourcePath,
|
|
string destination,
|
|
int targetMaxDimension,
|
|
bool onlyDownscaleUncompressed,
|
|
TexHeaderInfo? headerInfo = null)
|
|
{
|
|
TexHeaderInfo? header = headerInfo;
|
|
int dropCount = -1;
|
|
int originalWidth = 0;
|
|
int originalHeight = 0;
|
|
int originalMipLevels = 0;
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(sourcePath))
|
|
{
|
|
_logger.LogWarning("Cannot trim mip levels for texture {Hash}; source path missing: {Path}", hash, sourcePath);
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (header is null && TryReadTexHeader(sourcePath, out var discoveredHeader))
|
|
{
|
|
header = discoveredHeader;
|
|
}
|
|
|
|
if (header is TexHeaderInfo info)
|
|
{
|
|
if (onlyDownscaleUncompressed && IsBlockCompressedFormat(info.Format))
|
|
{
|
|
_logger.LogTrace("Skipping mip trim for texture {Hash}; block compressed format {Format}.", hash, info.Format);
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (info.MipLevels <= 1)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
var headerDepth = info.Depth == 0 ? 1 : info.Depth;
|
|
if (!ShouldTrimDimensions(info.Width, info.Height, headerDepth, targetMaxDimension))
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
}
|
|
|
|
using var original = TexFileHelper.Load(sourcePath);
|
|
var meta = original.Meta;
|
|
originalWidth = meta.Width;
|
|
originalHeight = meta.Height;
|
|
originalMipLevels = meta.MipLevels;
|
|
if (meta.MipLevels <= 1)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
if (!ShouldTrim(meta, targetMaxDimension))
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
var targetSize = CalculateTargetSize(meta.Width, meta.Height, targetMaxDimension);
|
|
dropCount = CalculateDropCount(meta, targetSize.width, targetSize.height);
|
|
if (dropCount <= 0)
|
|
{
|
|
return Task.FromResult(false);
|
|
}
|
|
|
|
using var trimmed = TrimMipChain(original, dropCount);
|
|
TexFileHelper.Save(destination, trimmed);
|
|
RegisterDownscaledTexture(hash, sourcePath, destination);
|
|
_logger.LogDebug("Trimmed {DropCount} top mip level(s) for texture {Hash} -> {Path}", dropCount, hash, destination);
|
|
return Task.FromResult(true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(
|
|
ex,
|
|
"Failed to trim mips for texture {Hash} from {SourcePath} -> {Destination}. TargetMax={TargetMaxDimension}, OnlyUncompressed={OnlyDownscaleUncompressed}, HeaderFormat={HeaderFormat}, OriginalSize={OriginalWidth}x{OriginalHeight}, OriginalMipLevels={OriginalMipLevels}, DropAttempt={DropCount}",
|
|
hash,
|
|
sourcePath,
|
|
destination,
|
|
targetMaxDimension,
|
|
onlyDownscaleUncompressed,
|
|
header?.Format,
|
|
originalWidth,
|
|
originalHeight,
|
|
originalMipLevels,
|
|
dropCount);
|
|
TryDelete(destination);
|
|
return Task.FromResult(false);
|
|
}
|
|
}
|
|
|
|
private static int CalculateDropCount(in TexMeta meta, int targetWidth, int targetHeight)
|
|
{
|
|
var drop = 0;
|
|
var width = meta.Width;
|
|
var height = meta.Height;
|
|
|
|
while ((width > targetWidth || height > targetHeight) && drop + 1 < meta.MipLevels)
|
|
{
|
|
drop++;
|
|
width = ReduceDimension(width);
|
|
height = ReduceDimension(height);
|
|
}
|
|
|
|
return drop;
|
|
}
|
|
|
|
private static ScratchImage TrimMipChain(ScratchImage source, int dropCount)
|
|
{
|
|
var meta = source.Meta;
|
|
var newMeta = meta;
|
|
newMeta.MipLevels = meta.MipLevels - dropCount;
|
|
newMeta.Width = ReduceDimension(meta.Width, dropCount);
|
|
newMeta.Height = ReduceDimension(meta.Height, dropCount);
|
|
if (meta.Dimension == TexDimension.Tex3D)
|
|
{
|
|
newMeta.Depth = ReduceDimension(meta.Depth, dropCount);
|
|
}
|
|
|
|
var result = ScratchImage.Initialize(newMeta);
|
|
CopyMipChainData(source, result, dropCount, meta);
|
|
return result;
|
|
}
|
|
|
|
private static unsafe void CopyMipChainData(ScratchImage source, ScratchImage destination, int dropCount, in TexMeta sourceMeta)
|
|
{
|
|
var destinationMeta = destination.Meta;
|
|
var arraySize = Math.Max(1, sourceMeta.ArraySize);
|
|
var isCube = sourceMeta.IsCubeMap;
|
|
var isVolume = sourceMeta.Dimension == TexDimension.Tex3D;
|
|
|
|
for (var item = 0; item < arraySize; item++)
|
|
{
|
|
for (var mip = 0; mip < destinationMeta.MipLevels; mip++)
|
|
{
|
|
var sourceMip = mip + dropCount;
|
|
var sliceCount = GetSliceCount(sourceMeta, sourceMip, isCube, isVolume);
|
|
|
|
for (var slice = 0; slice < sliceCount; slice++)
|
|
{
|
|
var srcImage = source.GetImage(sourceMip, item, slice);
|
|
var dstImage = destination.GetImage(mip, item, slice);
|
|
CopyImage(srcImage, dstImage);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static int GetSliceCount(in TexMeta meta, int mip, bool isCube, bool isVolume)
|
|
{
|
|
if (isCube)
|
|
{
|
|
return 6;
|
|
}
|
|
|
|
if (isVolume)
|
|
{
|
|
return Math.Max(1, meta.Depth >> mip);
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private static unsafe void CopyImage(in OtterImage source, in OtterImage destination)
|
|
{
|
|
var srcPtr = (byte*)source.Pixels;
|
|
var dstPtr = (byte*)destination.Pixels;
|
|
var bytesToCopy = Math.Min(source.SlicePitch, destination.SlicePitch);
|
|
Buffer.MemoryCopy(srcPtr, dstPtr, destination.SlicePitch, bytesToCopy);
|
|
}
|
|
|
|
private static int ReduceDimension(int value, int iterations)
|
|
{
|
|
var result = value;
|
|
for (var i = 0; i < iterations; i++)
|
|
{
|
|
result = ReduceDimension(result);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static int ReduceDimension(int value)
|
|
=> value <= 1 ? 1 : Math.Max(1, value / 2);
|
|
|
|
private static bool ShouldTrim(in TexMeta meta, int targetMaxDimension)
|
|
{
|
|
var depth = meta.Dimension == TexDimension.Tex3D ? Math.Max(1, meta.Depth) : 1;
|
|
return ShouldTrimDimensions(meta.Width, meta.Height, depth, targetMaxDimension);
|
|
}
|
|
|
|
private static bool ShouldTrimDimensions(int width, int height, int depth, int targetMaxDimension)
|
|
{
|
|
if (width <= targetMaxDimension && height <= targetMaxDimension && depth <= targetMaxDimension)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private int ResolveTargetMaxDimension()
|
|
{
|
|
var configured = _playerPerformanceConfigService.Current.TextureDownscaleMaxDimension;
|
|
if (configured <= 0)
|
|
{
|
|
return DefaultTargetMaxDimension;
|
|
}
|
|
|
|
return Math.Clamp(configured, BlockMultiple, MaxSupportedTargetDimension);
|
|
}
|
|
|
|
private readonly struct TexHeaderInfo
|
|
{
|
|
public TexHeaderInfo(ushort width, ushort height, ushort depth, ushort mipLevels, TexFile.TextureFormat format)
|
|
{
|
|
Width = width;
|
|
Height = height;
|
|
Depth = depth;
|
|
MipLevels = mipLevels;
|
|
Format = format;
|
|
}
|
|
|
|
public ushort Width { get; }
|
|
public ushort Height { get; }
|
|
public ushort Depth { get; }
|
|
public ushort MipLevels { get; }
|
|
public TexFile.TextureFormat Format { get; }
|
|
}
|
|
|
|
private static bool TryReadTexHeader(string path, out TexHeaderInfo header)
|
|
{
|
|
header = default;
|
|
|
|
try
|
|
{
|
|
Span<byte> buffer = stackalloc byte[16];
|
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
|
var read = stream.Read(buffer);
|
|
if (read < buffer.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var formatValue = BinaryPrimitives.ReadInt32LittleEndian(buffer[4..8]);
|
|
var format = (TexFile.TextureFormat)formatValue;
|
|
var width = BinaryPrimitives.ReadUInt16LittleEndian(buffer[8..10]);
|
|
var height = BinaryPrimitives.ReadUInt16LittleEndian(buffer[10..12]);
|
|
var depth = BinaryPrimitives.ReadUInt16LittleEndian(buffer[12..14]);
|
|
var mipLevels = BinaryPrimitives.ReadUInt16LittleEndian(buffer[14..16]);
|
|
header = new TexHeaderInfo(width, height, depth, mipLevels, format);
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsBlockCompressedFormat(TexFile.TextureFormat format)
|
|
=> TryGetCompressionTarget(format, out _);
|
|
|
|
private static bool TryGetCompressionTarget(TexFile.TextureFormat format, out TextureCompressionTarget target)
|
|
{
|
|
if (BlockCompressedFormatMap.TryGetValue(unchecked((int)format), out var mapped))
|
|
{
|
|
target = mapped;
|
|
return true;
|
|
}
|
|
|
|
target = default;
|
|
return false;
|
|
}
|
|
|
|
private void RegisterDownscaledTexture(string hash, string sourcePath, string destination)
|
|
{
|
|
_downscaledPaths[hash] = destination;
|
|
_logger.LogDebug("Downscaled texture {Hash} -> {Path}", hash, destination);
|
|
|
|
var performanceConfig = _playerPerformanceConfigService.Current;
|
|
if (performanceConfig.KeepOriginalTextureFiles)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!TryReplaceCacheEntryWithDownscaled(hash, sourcePath, destination))
|
|
{
|
|
return;
|
|
}
|
|
|
|
TryDelete(sourcePath);
|
|
}
|
|
|
|
private bool TryReplaceCacheEntryWithDownscaled(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);
|
|
}
|
|
|
|
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
|
|
_fileCacheManager.WriteOutFullCsv();
|
|
|
|
_logger.LogTrace("Replaced cache entry for texture {Hash} to downscaled path {Path}", hash, destination);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to replace cache entry for texture {Hash}", hash);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private string? GetExistingDownscaledPath(string hash)
|
|
{
|
|
var candidate = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
|
|
return File.Exists(candidate) ? candidate : null;
|
|
}
|
|
|
|
private string GetDownscaledDirectory()
|
|
{
|
|
var directory = Path.Combine(_configService.Current.CacheFolder, "downscaled");
|
|
if (!Directory.Exists(directory))
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogTrace(ex, "Failed to create downscaled 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
|
|
}
|
|
}
|
|
|
|
}
|