using Dalamud.Interface; using Dalamud.Interface.Textures; using Dalamud.Interface.Textures.TextureWraps; using Dalamud.Plugin.Services; using Lumina.Data.Files; using OtterGui.Log; using OtterGui.Services; using OtterGui.Tasks; using OtterTex; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Tga; using SixLabors.ImageSharp.PixelFormats; using TerraFX.Interop.DirectX; using TerraFX.Interop.Windows; using Image = SixLabors.ImageSharp.Image; namespace Penumbra.Import.Textures; public sealed class TextureManager(IDataManager gameData, Logger logger, ITextureProvider textureProvider, IUiBuilder uiBuilder) : SingleTaskQueue, IDisposable, IService { private readonly Logger _logger = logger; private readonly ConcurrentDictionary _tasks = new(); private bool _disposed; public IReadOnlyDictionary Tasks => _tasks; public void Dispose() { _disposed = true; foreach (var (_, cancel) in _tasks.Values.ToArray()) cancel.Cancel(); _tasks.Clear(); } public Task SavePng(string input, string output) => Enqueue(new SaveImageSharpAction(this, input, output, TextureType.Png)); public Task SavePng(BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) => Enqueue(new SaveImageSharpAction(this, image, path, TextureType.Png, rgba, width, height)); public Task SaveTga(string input, string output) => Enqueue(new SaveImageSharpAction(this, input, output, TextureType.Targa)); public Task SaveTga(BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) => Enqueue(new SaveImageSharpAction(this, image, path, TextureType.Targa, rgba, width, height)); public Task SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input, string output) => Enqueue(new SaveAsAction(this, type, mipMaps, asTex, input, output)); public Task SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) => Enqueue(new SaveAsAction(this, type, mipMaps, asTex, image, path, rgba, width, height)); private Task Enqueue(IAction action) { if (_disposed) return Task.FromException(new ObjectDisposedException(nameof(TextureManager))); Task t; lock (_tasks) { t = _tasks.GetOrAdd(action, a => { var token = new CancellationTokenSource(); var task = Enqueue(a, token.Token); task.ContinueWith(_ => _tasks.TryRemove(a, out var unused), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); return (task, token); }).Item1; } return t; } private class SaveImageSharpAction : IAction { private readonly TextureManager _textures; private readonly string _outputPath; private readonly ImageInputData _input; private readonly TextureType _type; public SaveImageSharpAction(TextureManager textures, string input, string output, TextureType type) { _textures = textures; _input = new ImageInputData(input); _outputPath = output; _type = type; if (_type.ReduceToBehaviour() is not TextureType.Png) throw new ArgumentOutOfRangeException(nameof(type), type, $"Can not save as {type} with ImageSharp."); } public SaveImageSharpAction(TextureManager textures, BaseImage image, string path, TextureType type, byte[]? rgba = null, int width = 0, int height = 0) { _textures = textures; _input = new ImageInputData(image, rgba, width, height); _outputPath = path; _type = type; if (_type.ReduceToBehaviour() is not TextureType.Png) throw new ArgumentOutOfRangeException(nameof(type), type, $"Can not save as {type} with ImageSharp."); } public void Execute(CancellationToken cancel) { _textures._logger.Information($"[{nameof(TextureManager)}] Saving {_input} as {_type} to {_outputPath}..."); var (image, rgba, width, height) = _input.GetData(_textures); cancel.ThrowIfCancellationRequested(); Image? data = null; if (image.Type is TextureType.Unknown) { if (rgba != null && width > 0 && height > 0) data = ConvertToPng(rgba, width, height).AsPng!; } else { data = ConvertToPng(image, cancel, rgba).AsPng!; } cancel.ThrowIfCancellationRequested(); switch (_type) { case TextureType.Png: data?.SaveAsync(_outputPath, new PngEncoder { CompressionLevel = PngCompressionLevel.NoCompression }, cancel) .Wait(cancel); return; case TextureType.Targa: data?.SaveAsync(_outputPath, new TgaEncoder { Compression = TgaCompression.None, BitsPerPixel = TgaBitsPerPixel.Pixel32, }, cancel).Wait(cancel); return; } } public override string ToString() => $"{_input} to {_outputPath} PNG"; public bool Equals(IAction? other) { if (other is not SaveImageSharpAction rhs) return false; return string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase) && _input.Equals(rhs._input); } public override int GetHashCode() => HashCode.Combine(_outputPath.ToLowerInvariant(), _input); } private class SaveAsAction : IAction { private readonly TextureManager _textures; private readonly string _outputPath; private readonly ImageInputData _input; private readonly CombinedTexture.TextureSaveType _type; private readonly bool _mipMaps; private readonly bool _asTex; public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input, string output) { _textures = textures; _input = new ImageInputData(input); _outputPath = output; _type = type; _mipMaps = mipMaps; _asTex = asTex; } public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0) { _textures = textures; _input = new ImageInputData(image, rgba, width, height); _outputPath = path; _type = type; _mipMaps = mipMaps; _asTex = asTex; } public void Execute(CancellationToken cancel) { _textures._logger.Information( $"[{nameof(TextureManager)}] Saving {_input} as {_type} {(_asTex ? ".tex" : ".dds")} file{(_mipMaps ? " with mip maps" : string.Empty)} to {_outputPath}..."); var (image, rgba, width, height) = _input.GetData(_textures); if (image.Type is TextureType.Unknown) { if (rgba != null && width > 0 && height > 0) image = ConvertToDds(rgba, width, height); else return; } var imageTypeBehaviour = image.Type.ReduceToBehaviour(); var dds = _type switch { CombinedTexture.TextureSaveType.AsIs when imageTypeBehaviour is TextureType.Png => ConvertToRgbaDds(image, _mipMaps, cancel, rgba, width, height), CombinedTexture.TextureSaveType.AsIs when imageTypeBehaviour is TextureType.Dds => AddMipMaps(image.AsDds!, _mipMaps), CombinedTexture.TextureSaveType.Bitmap => ConvertToRgbaDds(image, _mipMaps, cancel, rgba, width, height), CombinedTexture.TextureSaveType.BC1 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC1UNorm, cancel, rgba, width, height), CombinedTexture.TextureSaveType.BC3 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC3UNorm, cancel, rgba, width, height), CombinedTexture.TextureSaveType.BC4 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC4UNorm, cancel, rgba, width, height), CombinedTexture.TextureSaveType.BC5 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC5UNorm, cancel, rgba, width, height), CombinedTexture.TextureSaveType.BC7 => _textures.ConvertToCompressedDds(image, _mipMaps, DXGIFormat.BC7UNorm, cancel, rgba, width, height), _ => throw new Exception("Wrong save type."), }; cancel.ThrowIfCancellationRequested(); if (_asTex) SaveTex(_outputPath, dds.AsDds!); else dds.AsDds!.SaveDDS(_outputPath); } public override string ToString() => $"{_input} to {_outputPath} {_type} {(_asTex ? "TEX" : "DDS")}{(_mipMaps ? " with MipMaps" : string.Empty)}"; public bool Equals(IAction? other) { if (other is not SaveAsAction rhs) return false; return _type == rhs._type && _mipMaps == rhs._mipMaps && _asTex == rhs._asTex && string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase) && _input.Equals(rhs._input); } public override int GetHashCode() => HashCode.Combine(_outputPath.ToLowerInvariant(), _type, _mipMaps, _asTex, _input); } /// Load a texture wrap for a given image. public IDalamudTextureWrap LoadTextureWrap(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0) { (rgba, width, height) = GetData(image, rgba, width, height); return LoadTextureWrap(rgba, width, height); } /// Load a texture wrap for a given image. public IDalamudTextureWrap LoadTextureWrap(byte[] rgba, int width, int height) => textureProvider.CreateFromRaw(RawImageSpecification.Rgba32(width, height), rgba, "Penumbra.Texture"); /// Load any supported file from game data or drive depending on extension and if the path is rooted. public (BaseImage, TextureType) Load(string path) => Path.GetExtension(path).ToLowerInvariant() switch { ".dds" => (LoadDds(path), TextureType.Dds), ".png" => (LoadImageSharp(path), TextureType.Png), ".tga" => (LoadImageSharp(path), TextureType.Targa), ".bmp" => (LoadImageSharp(path), TextureType.Bitmap), ".tex" => (LoadTex(path), TextureType.Tex), _ => throw new Exception($"Extension {Path.GetExtension(path)} unknown."), }; /// Load a .tex file from game data or drive depending on if the path is rooted. public BaseImage LoadTex(string path) { using var stream = OpenTexStream(path); return TexFileParser.Parse(stream); } /// Load a .dds file from drive using OtterTex. public BaseImage LoadDds(string path) => ScratchImage.LoadDDS(path); /// Load a supported file type from drive using ImageSharp. public BaseImage LoadImageSharp(string path) { using var stream = File.OpenRead(path); return Image.Load(stream); } /// Convert an existing image to ImageSharp. Does not create a deep copy of an existing ImageSharp file and just returns the existing one. public static BaseImage ConvertToPng(BaseImage input, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0) { switch (input.Type.ReduceToBehaviour()) { case TextureType.Png: return input; case TextureType.Dds: { (rgba, width, height) = GetData(input, rgba, width, height); cancel.ThrowIfCancellationRequested(); return ConvertToPng(rgba, width, height); } default: return new BaseImage(); } } /// Convert an existing image to a RGBA32 .dds. Does not create a deep copy of an existing RGBA32 dds and just returns the existing one. public static BaseImage ConvertToRgbaDds(BaseImage input, bool mipMaps, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0) { switch (input.Type.ReduceToBehaviour()) { case TextureType.Png: { (rgba, width, height) = GetData(input, rgba, width, height); cancel.ThrowIfCancellationRequested(); var dds = ConvertToDds(rgba, width, height).AsDds!; cancel.ThrowIfCancellationRequested(); return AddMipMaps(dds, mipMaps); } case TextureType.Dds: { var scratch = input.AsDds!; if (rgba == null) return CreateUncompressed(scratch, mipMaps, cancel); (rgba, width, height) = GetData(input, rgba, width, height); cancel.ThrowIfCancellationRequested(); var dds = ConvertToDds(rgba, width, height).AsDds!; cancel.ThrowIfCancellationRequested(); return AddMipMaps(dds, mipMaps); } default: return new BaseImage(); } } /// Convert an existing image to a block compressed .dds. Does not create a deep copy of an existing dds of the correct format and just returns the existing one. public BaseImage ConvertToCompressedDds(BaseImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0) { switch (input.Type.ReduceToBehaviour()) { case TextureType.Png: { (rgba, width, height) = GetData(input, rgba, width, height); cancel.ThrowIfCancellationRequested(); var dds = ConvertToDds(rgba, width, height).AsDds!; cancel.ThrowIfCancellationRequested(); return CreateCompressed(dds, mipMaps, format, cancel); } case TextureType.Dds: { var scratch = input.AsDds!; return CreateCompressed(scratch, mipMaps, format, cancel); } default: return new BaseImage(); } } public static BaseImage ConvertToPng(byte[] rgba, int width, int height) => Image.LoadPixelData(rgba, width, height); public static BaseImage ConvertToDds(byte[] rgba, int width, int height) { var scratch = ScratchImage.FromRGBA(rgba, width, height, out var i).ThrowIfError(i); return scratch.Convert(DXGIFormat.B8G8R8A8UNorm); } public bool GameFileExists(string path) => gameData.FileExists(path); /// Add up to 13 mip maps to the input if mip maps is true, otherwise return input. public static ScratchImage AddMipMaps(ScratchImage input, bool mipMaps) { var numMips = mipMaps ? Math.Min(13, 1 + BitOperations.Log2((uint)Math.Max(input.Meta.Width, input.Meta.Height))) : 1; if (numMips == input.Meta.MipLevels) return input; var flags = (Dalamud.Utility.Util.IsWine() ? FilterFlags.ForceNonWIC : 0) | FilterFlags.SeparateAlpha; var ec = input.GenerateMipMaps(out var ret, numMips, flags); if (ec != ErrorCode.Ok) throw new Exception( $"Could not create the requested {numMips} mip maps (input has {input.Meta.MipLevels}) with flags [{flags}], maybe retry with the top-right checkbox unchecked:\n{ec}"); return ret; } /// Create an uncompressed .dds (optionally with mip maps) from the input. Returns input (+ mipmaps) if it is already uncompressed. public static ScratchImage CreateUncompressed(ScratchImage input, bool mipMaps, CancellationToken cancel) { if (input.Meta.Format == DXGIFormat.B8G8R8A8UNorm) return AddMipMaps(input, mipMaps); input = input.Meta.Format.IsCompressed() ? input.Decompress(DXGIFormat.B8G8R8A8UNorm) : input.Convert(DXGIFormat.B8G8R8A8UNorm); cancel.ThrowIfCancellationRequested(); return AddMipMaps(input, mipMaps); } /// Create a BC3 or BC7 block-compressed .dds from the input (optionally with mipmaps). Returns input (+ mipmaps) if it is already the correct format. public unsafe ScratchImage CreateCompressed(ScratchImage input, bool mipMaps, DXGIFormat format, CancellationToken cancel) { if (input.Meta.Format == format) return input; if (input.Meta.Format.IsCompressed()) { input = input.Decompress(DXGIFormat.B8G8R8A8UNorm); cancel.ThrowIfCancellationRequested(); } input = AddMipMaps(input, mipMaps); cancel.ThrowIfCancellationRequested(); // See https://github.com/microsoft/DirectXTex/wiki/Compress#parameters for the format condition. if (format is DXGIFormat.BC6HUF16 or DXGIFormat.BC6HSF16 or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB) { ref var device = ref *(ID3D11Device*)uiBuilder.DeviceHandle; IDXGIDevice* dxgiDevice; Marshal.ThrowExceptionForHR(device.QueryInterface(TerraFX.Interop.Windows.Windows.__uuidof(), (void**)&dxgiDevice)); try { IDXGIAdapter* adapter = null; Marshal.ThrowExceptionForHR(dxgiDevice->GetAdapter(&adapter)); try { dxgiDevice->Release(); dxgiDevice = null; ID3D11Device* deviceClone = null; ID3D11DeviceContext* contextClone = null; var featureLevel = device.GetFeatureLevel(); Marshal.ThrowExceptionForHR(DirectX.D3D11CreateDevice( adapter, D3D_DRIVER_TYPE.D3D_DRIVER_TYPE_UNKNOWN, HMODULE.NULL, device.GetCreationFlags(), &featureLevel, 1, D3D11.D3D11_SDK_VERSION, &deviceClone, null, &contextClone)); try { adapter->Release(); adapter = null; return input.Compress((nint)deviceClone, format, CompressFlags.Parallel); } finally { if (contextClone is not null) contextClone->Release(); if (deviceClone is not null) deviceClone->Release(); } } finally { if (adapter is not null) adapter->Release(); } } finally { if (dxgiDevice is not null) dxgiDevice->Release(); } } return input.Compress(format, CompressFlags.BC7Quick | CompressFlags.Parallel); } /// Load a tex file either from game data if the path is not rooted, or from drive if it is rooted. private Stream OpenTexStream(string path) { if (Path.IsPathRooted(path)) return File.OpenRead(path); var file = gameData.GetFile(path); return file != null ? new MemoryStream(file.Data) : throw new Exception($"Unable to obtain \"{path}\" from game files."); } /// Obtain the checked rgba data, width and height for an image. private static (byte[], int, int) GetData(BaseImage input, byte[]? rgba, int width, int height) { if (rgba == null) return input.GetPixelData(); if (width == 0 || height == 0) (width, height) = input.Dimensions; return width * height * 4 != rgba.Length ? input.GetPixelData() : (rgba, width, height); } /// Save a .dds file as .tex file with appropriately changed header. public static void SaveTex(string path, ScratchImage input) { var header = input.ToTexHeader(); if (header.Format == TexFile.TextureFormat.Unknown) throw new Exception($"Could not save tex file with format {input.Meta.Format}, not convertible to a valid .tex format."); using var stream = File.Open(path, File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew); using var w = new BinaryWriter(stream); header.Write(w); w.Write(input.Pixels); // Necessary due to the GC being allowed to collect after the last invocation of an object, // thus invalidating the ReadOnlySpan. GC.KeepAlive(input); } private readonly struct ImageInputData : IEquatable { private readonly string? _inputPath; private readonly BaseImage _image; private readonly byte[]? _rgba; private readonly int _width; private readonly int _height; public ImageInputData(string inputPath) { _inputPath = inputPath; _image = new BaseImage(); _rgba = null; _width = 0; _height = 0; } public ImageInputData(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0) { _inputPath = null; _image = image.Width == 0 || image.Height == 0 ? new BaseImage() : image; _rgba = rgba?.ToArray(); _width = width; _height = height; } public (BaseImage Image, byte[]? Rgba, int Width, int Height) GetData(TextureManager textures) { if (_inputPath == null) return (_image, _rgba, _width, _height); if (!File.Exists(_inputPath)) throw new FileNotFoundException($"Input texture file {_inputPath} not Found.", _inputPath); var (image, _) = textures.Load(_inputPath); return (image, null, 0, 0); } public bool Equals(ImageInputData rhs) { if (_inputPath != null) return string.Equals(_inputPath, rhs._inputPath, StringComparison.OrdinalIgnoreCase); if (rhs._inputPath != null) return false; if (_image.Image != null) return ReferenceEquals(_image.Image, rhs._image.Image); return _width == rhs._width && _height == rhs._height && _rgba != null && rhs._rgba != null && _rgba.SequenceEqual(rhs._rgba); } public override string ToString() => _inputPath ?? _image.Type switch { TextureType.Unknown => $"Custom {_width} x {_height} RGBA Image", TextureType.Dds => $"Custom {_width} x {_height} {_image.Format} Image", TextureType.Tex => $"Custom {_width} x {_height} {_image.Format} Image", TextureType.Png => $"Custom {_width} x {_height} .png Image", TextureType.Targa => $"Custom {_width} x {_height} .tga Image", TextureType.Bitmap => $"Custom {_width} x {_height} RGBA Image", _ => "Unknown Image", }; public override int GetHashCode() => _inputPath != null ? _inputPath.ToLowerInvariant().GetHashCode() : HashCode.Combine(_width, _height); public override bool Equals(object? obj) => obj is ImageInputData o && Equals(o); } }