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 _logger; private readonly LightlessConfigService _configService; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly FileCacheManager _fileCacheManager; private readonly ConcurrentDictionary _activeJobs = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _downscaledPaths = new(StringComparer.OrdinalIgnoreCase); private readonly SemaphoreSlim _downscaleSemaphore = new(4); private static readonly IReadOnlyDictionary BlockCompressedFormatMap = new Dictionary { [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 logger, LightlessConfigService configService, PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager) { _logger = logger; _configService = configService; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; } public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind) => ScheduleDownscale(hash, filePath, () => mapKind); public void ScheduleDownscale(string hash, string filePath, Func mapKindFactory) { if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return; if (_activeJobs.ContainsKey(hash)) return; _activeJobs[hash] = Task.Run(async () => { TextureMapKind mapKind; try { mapKind = mapKindFactory(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash); return; } await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false); }, CancellationToken.None); } public bool ShouldScheduleDownscale(string filePath) { if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return false; var performanceConfig = _playerPerformanceConfigService.Current; return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale; } 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? hashes, CancellationToken token) { if (hashes is null) { return Task.CompletedTask; } var pending = new List(); var seen = new HashSet(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(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 ?? "", 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 image, int width, int height) { const int BytesPerPixel = 4; var requiredLength = width * height * BytesPerPixel; static ScratchImage Create(ReadOnlySpan 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.Shared.Rent(requiredLength); try { var rentedSpan = rented.AsSpan(0, requiredLength); image.CopyPixelDataTo(rentedSpan); return Create(rentedSpan, width, height); } finally { ArrayPool.Shared.Return(rented); } } private static bool IsIndexMap(TextureMapKind kind) => kind is TextureMapKind.Mask or TextureMapKind.Index; private Task 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 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, removeDerivedFiles: false); } _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 } } }