diff --git a/docs/README.md b/docs/README.md index 141e0d5..054abd2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,7 @@ ![test](https://github.com/matyalatte/UE4-DDS-tools/actions/workflows/test.yml/badge.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -# UE4-DDS-Tools ver0.5.0 +# UE4-DDS-Tools ver0.5.1 Texture modding tools for UE games. You can inject texture files (.dds, .tga, .hdr, etc.) into UE assets. diff --git a/docs/changelog.txt b/docs/changelog.txt index 104d8fc..b66ecce 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -3,6 +3,7 @@ ver 0.5.1 - Added tooltips to GUI - Fixed a bug that max size will be uexp's max size. - Fixed a bug that ubulk flags won't be updated correctly. +- Enabled "skip_non_texture" option and cubic filter as default. ver 0.5.0 - Supported Texture2DArray, TextureCubeArray, and VolumeTexture diff --git a/src/directx/dds.py b/src/directx/dds.py index 41b4205..df14270 100644 --- a/src/directx/dds.py +++ b/src/directx/dds.py @@ -18,7 +18,7 @@ class PF_FLAGS(IntEnum): - '''dwFlags for DDS_PIXELFORMAT''' + """dwFlags for DDS_PIXELFORMAT""" # ALPHAPIXELS = 0x00000001 # ALPHA = 0x00000002 FOURCC = 0x00000004 @@ -67,7 +67,7 @@ def __init__(self): self.bit_mask = (c.c_uint32 * 4)((0) * 4) def get_dxgi(self) -> DXGI_FORMAT: - '''Similar method as GetDXGIFormat in DirectXTex/DDSTextureLoader/DDSTextureLoader12.cpp''' + """Similar method as GetDXGIFormat in DirectXTex/DDSTextureLoader/DDSTextureLoader12.cpp""" if not self.is_canonical(): raise RuntimeError(f"Non-standard fourCC detected. ({self.fourCC.decode()})") @@ -249,7 +249,7 @@ def is_array(self): def is_hdr(name: str): - return 'BC6' in name or 'FLOAT' in name or 'INT' in name or 'SNORM' in name + return "BC6" in name or "FLOAT" in name or "INT" in name or "SNORM" in name def convertible_to_tga(name: str): @@ -270,10 +270,10 @@ def read_buffer(f: IOBase, size: int, end_offset: int): class DDSHeader(c.LittleEndianStructure): - MAGIC = b'DDS ' + MAGIC = b"DDS " _pack_ = 1 _fields_ = [ - ("magic", c.c_char * 4), # Magic == 'DDS ' + ("magic", c.c_char * 4), # Magic == "DDS " ("head_size", c.c_uint32), # Size == 124 ("flags", c.c_uint32), # DDS_FLAGS ("height", c.c_uint32), @@ -334,7 +334,7 @@ def read(f: IOBase) -> "DDSHeader": @staticmethod def read_from_file(file_name: str) -> "DDSHeader": """Read dds header from a file.""" - with open(file_name, 'rb') as f: + with open(file_name, "rb") as f: head = DDSHeader.read(f) return head @@ -390,16 +390,16 @@ def is_hdr(self): def is_normals(self): dxgi = self.get_format_as_str() - return 'BC5' in dxgi or dxgi == 'R8G8_UNORM' + return "BC5" in dxgi or dxgi == "R8G8_UNORM" def get_format_as_str(self): return self.dxgi_format.name def is_srgb(self): - return 'SRGB' in self.dxgi_format.name + return "SRGB" in self.dxgi_format.name def is_int(self): - return 'UINT' in self.dxgi_format.name or 'SINT' in self.dxgi_format.name + return "UINT" in self.dxgi_format.name or "SINT" in self.dxgi_format.name def is_canonical(self): return self.pixel_format.is_canonical() @@ -456,7 +456,7 @@ def cail(val, unit): slice_size += _width * _height * byte_per_pixel if slice_size != int(slice_size): raise RuntimeError( - 'The size of mipmap data is not int. This is unexpected.' + "The size of mipmap data is not int. This is unexpected." ) width, height = width // 2, height // 2 width, height = max(block_size, width), max(block_size, height) @@ -464,16 +464,16 @@ def cail(val, unit): return mipmap_size_list, int(slice_size) def print(self): - print(f' type: {self.get_texture_type()}') - print(f' format: {self.get_format_as_str()}') - print(f' width: {self.width}') - print(f' height: {self.height}') + print(f" type: {self.get_texture_type()}") + print(f" format: {self.get_format_as_str()}") + print(f" width: {self.width}") + print(f" height: {self.height}") if self.is_3d(): - print(f' depth: {self.depth}') + print(f" depth: {self.depth}") elif self.is_array(): - print(f' array_size: {self.get_array_size()}') + print(f" array_size: {self.get_array_size()}") else: - print(f' mipmaps: {self.mipmap_num}') + print(f" mipmaps: {self.mipmap_num}") def disassemble(self): self.update(self.width, self.height, 1, self.mipmap_num, self.dxgi_format, self.is_cube(), 1) @@ -493,10 +493,10 @@ def __init__(self, header: DDSHeader, slices: list[bytes] = None, mipmap_sizes: @staticmethod def load(file: str, verbose=False): - if file[-3:] not in ['dds', 'DDS']: - raise RuntimeError(f'Not DDS. ({file})') - print('load: ' + file) - with open(file, 'rb') as f: + if file[-3:] not in ["dds", "DDS"]: + raise RuntimeError(f"Not DDS. ({file})") + print("load: " + file) + with open(file, "rb") as f: end_offset = get_size(f) # read header @@ -521,12 +521,12 @@ def load(file: str, verbose=False): # save as dds def save(self, file: str): - print('save: {}'.format(file)) + print("save: {}".format(file)) folder = os.path.dirname(file) - if folder not in ['.', ''] and not os.path.exists(folder): + if folder not in [".", ""] and not os.path.exists(folder): mkdir(folder) - with open(file, 'wb') as f: + with open(file, "wb") as f: # write header self.header.write(f) @@ -574,6 +574,6 @@ def print(self, verbose): if verbose: # print mipmap info for i, size in zip(range(len(self.mipmap_size_list)), self.mipmap_size_list): - print(f' Mipmap {i}') + print(f" Mipmap {i}") width, height = size - print(f' size (w, h): ({width}, {height})') + print(f" size (w, h): ({width}, {height})") diff --git a/src/directx/dxgi_format.py b/src/directx/dxgi_format.py index 31d2a9b..b015f42 100644 --- a/src/directx/dxgi_format.py +++ b/src/directx/dxgi_format.py @@ -1,11 +1,11 @@ -'''Constants for DXGI formats +"""Constants for DXGI formats Notes: - Official document for DXGI formats https://docs.microsoft.com/en-us/windows/win32/api/dxgiformat/ne-dxgiformat-dxgi_format - Official repo for DDS https://github.com/microsoft/DirectXTex -''' +""" from enum import IntEnum @@ -302,18 +302,18 @@ def int_to_byte(n): # Used to detect DXGI format from fourCC FOURCC_TO_DXGI = [ - [[b'DXT1'], DXGI_FORMAT.BC1_UNORM], - [[b'DXT2', b'DXT3'], DXGI_FORMAT.BC2_UNORM], - [[b'DXT4', b'DXT5'], DXGI_FORMAT.BC3_UNORM], - [[b'ATI1', b'BC4U', b'3DC1'], DXGI_FORMAT.BC4_UNORM], - [[b'ATI2', b'BC5U', b'3DC2'], DXGI_FORMAT.BC5_UNORM], - [[b'BC4S'], DXGI_FORMAT.BC4_SNORM], - [[b'BC5S'], DXGI_FORMAT.BC5_SNORM], - [[b'BC6H'], DXGI_FORMAT.BC6H_UF16], - [[b'BC7L', b'BC7'], DXGI_FORMAT.BC7_UNORM], - [[b'RGBG'], DXGI_FORMAT.R8G8_B8G8_UNORM], - [[b'GRGB'], DXGI_FORMAT.G8R8_G8B8_UNORM], - [[b'YUY2', b'UYVY'], DXGI_FORMAT.YUY2], + [[b"DXT1"], DXGI_FORMAT.BC1_UNORM], + [[b"DXT2", b"DXT3"], DXGI_FORMAT.BC2_UNORM], + [[b"DXT4", b"DXT5"], DXGI_FORMAT.BC3_UNORM], + [[b"ATI1", b"BC4U", b"3DC1"], DXGI_FORMAT.BC4_UNORM], + [[b"ATI2", b"BC5U", b"3DC2"], DXGI_FORMAT.BC5_UNORM], + [[b"BC4S"], DXGI_FORMAT.BC4_SNORM], + [[b"BC5S"], DXGI_FORMAT.BC5_SNORM], + [[b"BC6H"], DXGI_FORMAT.BC6H_UF16], + [[b"BC7L", b"BC7"], DXGI_FORMAT.BC7_UNORM], + [[b"RGBG"], DXGI_FORMAT.R8G8_B8G8_UNORM], + [[b"GRGB"], DXGI_FORMAT.G8R8_G8B8_UNORM], + [[b"YUY2", b"UYVY"], DXGI_FORMAT.YUY2], [[int_to_byte(36)], DXGI_FORMAT.R16G16B16A16_UNORM], [[int_to_byte(110)], DXGI_FORMAT.R16G16B16A16_SNORM], [[int_to_byte(111)], DXGI_FORMAT.R16_FLOAT], diff --git a/src/directx/texconv.py b/src/directx/texconv.py index 1179bab..5ca17f2 100644 --- a/src/directx/texconv.py +++ b/src/directx/texconv.py @@ -20,15 +20,15 @@ def get_os_name(): def is_windows(): - return get_os_name() == 'Windows' + return get_os_name() == "Windows" def is_linux(): - return get_os_name() == 'Linux' + return get_os_name() == "Linux" def is_mac(): - return get_os_name() == 'Darwin' + return get_os_name() == "Darwin" class Texconv: @@ -46,7 +46,7 @@ def load_dll(self, dll_path=None, com_initialized=False): elif is_linux(): dll_name = "libtexconv.so" else: - raise RuntimeError(f'This OS ({get_os_name()}) is unsupported.') + raise RuntimeError(f"This OS ({get_os_name()}) is unsupported.") dirname = os.path.dirname(file_path) dll_path = os.path.join(dirname, dll_name) dll_path2 = os.path.join(os.path.dirname(dirname), dll_name) # allow ../texconv.dll @@ -55,7 +55,7 @@ def load_dll(self, dll_path=None, com_initialized=False): if os.path.exists(dll_path2): dll_path = dll_path2 else: - raise RuntimeError(f'texconv not found. ({dll_path})') + raise RuntimeError(f"texconv not found. ({dll_path})") self.dll = ctypes.cdll.LoadLibrary(dll_path) self.com_initialized = com_initialized @@ -85,35 +85,35 @@ def convert_dds_to(self, file: str, out=None, fmt="tga", return name if verbose: - print(f'DXGI_FORMAT: {dds_header.get_format_as_str()}') + print(f"DXGI_FORMAT: {dds_header.get_format_as_str()}") args = [] if dds_header.is_hdr(): - ext = 'hdr' + ext = "hdr" if fmt == "tga": fmt = ext if not dds_header.convertible_to_hdr(): - args += ['-f', 'fp32'] + args += ["-f", "fp32"] else: ext = "tga" if not dds_header.convertible_to_tga(): - args += ['-f', 'rgba'] + args += ["-f", "rgba"] if dds_header.is_int(): - msg = f'Int format detected. ({dds_header.get_format_as_str()})\n It might not be converted correctly.' + msg = f"Int format detected. ({dds_header.get_format_as_str()})\n It might not be converted correctly." print(msg) - args2 = ['-ft', fmt] + args2 = ["-ft", fmt] if dds_header.is_normals(): - args2 += ['-reconstructz'] + args2 += ["-reconstructz"] if invert_normals: - args2 += ['-inverty'] + args2 += ["-inverty"] if dds_header.is_cube(): name = os.path.join(out, os.path.basename(file)) - name = '.'.join(name.split('.')[:-1] + [fmt]) + name = ".".join(name.split(".")[:-1] + [fmt]) temp = ".".join(file.split(".")[:-1] + [ext]) self.__cube_to_image(file, temp, args, cubemap_layout=cubemap_layout, verbose=verbose) if fmt == ext: @@ -123,7 +123,7 @@ def convert_dds_to(self, file: str, out=None, fmt="tga", else: out = self.__texconv(file, args + args2, out=out, verbose=verbose) name = os.path.join(out, os.path.basename(file)) - name = '.'.join(name.split('.')[:-1] + [fmt]) + name = ".".join(name.split(".")[:-1] + [fmt]) return name def convert_to_dds(self, file: str, dxgi_format: DXGI_FORMAT, out=None, @@ -136,8 +136,8 @@ def convert_to_dds(self, file: str, dxgi_format: DXGI_FORMAT, out=None, dds_fmt = dxgi_format.name - if ('BC6' in dds_fmt or 'BC7' in dds_fmt) and (not is_windows()) and (not allow_slow_codec): - raise RuntimeError(f'Can NOT use CPU codec for {dds_fmt}. Or enable the "Allow Slow Codec" option.') + if ("BC6" in dds_fmt or "BC7" in dds_fmt) and (not is_windows()) and (not allow_slow_codec): + raise RuntimeError(f"Can NOT use CPU codec for {dds_fmt}. Or enable the 'Allow Slow Codec' option.") if dxgi_format.value > DXGI_FORMAT.get_max_canonical(): raise RuntimeError( f"DDS converter does NOT support {dds_fmt}.\n" @@ -145,28 +145,28 @@ def convert_to_dds(self, file: str, dxgi_format: DXGI_FORMAT, out=None, ) if not DXGI_FORMAT.is_valid_format(dds_fmt): - raise RuntimeError(f'Not DXGI format. ({dds_fmt})') + raise RuntimeError(f"Not DXGI format. ({dds_fmt})") if verbose: - print(f'DXGI_FORMAT: {dds_fmt}') + print(f"DXGI_FORMAT: {dds_fmt}") base_name = os.path.basename(file) - base_name = '.'.join(base_name.split('.')[:-1] + ['dds']) + base_name = ".".join(base_name.split(".")[:-1] + ["dds"]) - args = ['-f', dds_fmt] + args = ["-f", dds_fmt] if no_mip: - args += ['-m', '1'] + args += ["-m", "1"] if image_filter.upper() != "LINEAR": args += ["-if", image_filter.upper()] if ("BC5" in dds_fmt or dds_fmt == "R8G8_UNORM") and invert_normals: - args += ['-inverty'] + args += ["-inverty"] if export_as_cubemap: if is_hdr(dds_fmt): - temp_args = ['-f', 'fp32'] + temp_args = ["-f", "fp32"] else: - temp_args = ['-f', 'rgba'] + temp_args = ["-f", "rgba"] with tempfile.TemporaryDirectory() as temp_dir: temp = os.path.join(temp_dir, base_name) self.__image_to_cube(file, temp, temp_args, cubemap_layout=cubemap_layout, verbose=verbose) @@ -178,20 +178,20 @@ def convert_to_dds(self, file: str, dxgi_format: DXGI_FORMAT, out=None, def convert_nondds(self, file: str, out=None, fmt="tga", verbose=True): """Convert non-dds to non-dds.""" - out = self.__texconv(file, ['-ft', fmt], out=out, verbose=verbose) + out = self.__texconv(file, ["-ft", fmt], out=out, verbose=verbose) name = os.path.join(out, os.path.basename(file)) - name = '.'.join(name.split('.')[:-1] + [fmt]) + name = ".".join(name.split(".")[:-1] + [fmt]) return name def __texconv(self, file: str, args: list[str], out=None, verbose=True, allow_slow_codec=False): """Run texconv.""" if out is not None and isinstance(out, str): - args += ['-o', out] + args += ["-o", out] else: - out = '.' + out = "." - if out not in ['.', ''] and not os.path.exists(out): + if out not in [".", ""] and not os.path.exists(out): mkdir(out) args += ["-y"] @@ -226,7 +226,7 @@ def __image_to_cube(self, file: str, new_file: str, args: list[str], def __texassemble(self, file: str, new_file: str, args: list[str], verbose=True): """Run texassemble.""" out = os.path.dirname(new_file) - if out not in ['.', ''] and not os.path.exists(out): + if out not in [".", ""] and not os.path.exists(out): mkdir(out) args += ["-y", "-o", new_file, file] diff --git a/src/main.py b/src/main.py index ac5ea2d..635b27d 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,4 @@ -'''Main file for UE4-DDS-Tools.''' +"""Main file for UE4-DDS-Tools.""" # std libs import argparse import json @@ -14,13 +14,13 @@ from directx.dxgi_format import DXGI_FORMAT from directx.texconv import Texconv, is_windows -TOOL_VERSION = '0.5.0' +TOOL_VERSION = "0.5.1" # UE version: 4.0 ~ 5.1, ff7r, borderlands3 -UE_VERSIONS = ['4.' + str(i) for i in range(28)] + ['5.' + str(i) for i in range(2)] + ['ff7r', 'borderlands3'] +UE_VERSIONS = ["4." + str(i) for i in range(28)] + ["5." + str(i) for i in range(2)] + ["ff7r", "borderlands3"] # Supported file extensions. -TEXTURES = ['dds', 'tga', 'hdr'] +TEXTURES = ["dds", "tga", "hdr"] if is_windows(): TEXTURES += ["bmp", "jpg", "png"] @@ -30,59 +30,59 @@ def get_args(): # pragma: no cover parser = argparse.ArgumentParser() - parser.add_argument('file', help='uasset, texture file, or folder') - parser.add_argument('texture', nargs='?', help='texture file for injection mode.') - parser.add_argument('--save_folder', default='output', type=str, help='output folder') - parser.add_argument('--mode', default='inject', type=str, - help='valid, parse, inject, export, remove_mipmaps, check, convert, and copy are available.') - parser.add_argument('--version', default=None, type=str, - help='UE version. it will overwrite the argment in config.json.') - parser.add_argument('--export_as', default='dds', type=str, - help='format for export mode. dds, tga, png, jpg, and bmp are available.') - parser.add_argument('--convert_to', default='tga', type=str, + parser.add_argument("file", help="uasset, texture file, or folder") + parser.add_argument("texture", nargs="?", help="texture file for injection mode.") + parser.add_argument("--save_folder", default="output", type=str, help="output folder") + parser.add_argument("--mode", default="inject", type=str, + help="valid, parse, inject, export, remove_mipmaps, check, convert, and copy are available.") + parser.add_argument("--version", default=None, type=str, + help="UE version. it will overwrite the argment in config.json.") + parser.add_argument("--export_as", default="dds", type=str, + help="format for export mode. dds, tga, png, jpg, and bmp are available.") + parser.add_argument("--convert_to", default="tga", type=str, help=("format for convert mode." "tga, hdr, png, jpg, bmp, and DXGI formats (e.g. BC1_UNORM) are available.")) - parser.add_argument('--no_mipmaps', action='store_true', - help='force no mips to dds and uasset.') - parser.add_argument('--force_uncompressed', action='store_true', - help='use uncompressed format for compressed texture assets.') - parser.add_argument('--disable_tempfile', action='store_true', + parser.add_argument("--no_mipmaps", action="store_true", + help="force no mips to dds and uasset.") + parser.add_argument("--force_uncompressed", action="store_true", + help="use uncompressed format for compressed texture assets.") + parser.add_argument("--disable_tempfile", action="store_true", help="store temporary files in the tool's directory.") - parser.add_argument('--skip_non_texture', action='store_true', + parser.add_argument("--skip_non_texture", action="store_true", help="disable errors about non-texture assets.") - parser.add_argument('--image_filter', default='linear', type=str, + parser.add_argument("--image_filter", default="linear", type=str, help=("image filter for mip generation." " point, linear, and cubic are available.")) - parser.add_argument('--save_detected_version', action='store_true', + parser.add_argument("--save_detected_version", action="store_true", help="save detected version for batch file methods. this is an option for check mode.") return parser.parse_args() def get_config(): - json_path = os.path.join(os.path.dirname(__file__), 'config.json') + json_path = os.path.join(os.path.dirname(__file__), "config.json") if not os.path.exists(json_path): return {} - with open(json_path, encoding='utf-8') as f: + with open(json_path, encoding="utf-8") as f: return json.load(f) def save_config(config): - json_path = os.path.join(os.path.dirname(__file__), 'config.json') - with open(json_path, 'w', encoding='utf-8') as f: + json_path = os.path.join(os.path.dirname(__file__), "config.json") + with open(json_path, "w", encoding="utf-8") as f: json.dump(config, f, indent=4, ensure_ascii=False) def parse(folder, file, args, texconv=None): - '''Parse mode (parse dds or uasset)''' + """Parse mode (parse dds or uasset)""" file = os.path.join(folder, file) - if get_ext(file) == 'dds': + if get_ext(file) == "dds": DDS.load(file, verbose=True) else: Uasset(file, version=args.version, verbose=True) def valid(folder, file, args, version=None, texconv=None): - '''Valid mode (check if the tool can read and write a file correctly.)''' + """Valid mode (check if the tool can read and write a file correctly.)""" if version is None: version = args.version @@ -90,7 +90,7 @@ def valid(folder, file, args, version=None, texconv=None): src_file = os.path.join(folder, file) new_file = os.path.join(temp_dir, file) - if get_ext(file) == 'dds': + if get_ext(file) == "dds": # read and write dds dds = DDS.load(src_file) dds.save(new_file) @@ -128,7 +128,7 @@ def search_texture_file(file_base, ext_list, index=None, index2=None): def inject(folder, file, args, texture_file=None, texconv=None): - '''Inject mode (inject dds into the asset)''' + """Inject mode (inject dds into the asset)""" # Read uasset uasset_file = os.path.join(folder, file) @@ -147,7 +147,7 @@ def inject(folder, file, args, texture_file=None, texconv=None): file_base, ext = os.path.splitext(texture_file) ext = ext[1:].lower() if ext not in TEXTURES: - raise RuntimeError(f'Unsupported texture format. ({ext})') + raise RuntimeError(f"Unsupported texture format. ({ext})") textures = asset.get_texture_list() ext_list = [ext] + TEXTURES @@ -170,7 +170,7 @@ def inject(folder, file, args, texture_file=None, texconv=None): tex.to_uncompressed() # Get a image as a DDS object - if get_ext(src) == 'dds': + if get_ext(src) == "dds": dds = DDS.load(src) else: with get_temp_dir(disable_tempfile=args.disable_tempfile) as temp_dir: @@ -212,7 +212,7 @@ def inject(folder, file, args, texture_file=None, texconv=None): def export(folder, file, args, texconv=None): - '''Export mode (export uasset as dds)''' + """Export mode (export uasset as dds)""" src_file = os.path.join(folder, file) new_file = os.path.join(args.save_folder, file) new_dir = os.path.dirname(new_file) @@ -232,16 +232,16 @@ def export(folder, file, args, texconv=None): for tex, i in zip(textures, range(len(textures))): if has_multi: # Add indices for multiple textures - file_name = os.path.splitext(new_file)[0] + f'.{i}.dds' + file_name = os.path.splitext(new_file)[0] + f".{i}.dds" else: - file_name = os.path.splitext(new_file)[0] + '.dds' + file_name = os.path.splitext(new_file)[0] + ".dds" if args.no_mipmaps: tex.remove_mipmaps() # Save texture dds = tex.get_dds() - if args.export_as == 'dds': + if args.export_as == "dds": dds.save(file_name) else: # Convert if the export format is not DDS @@ -254,7 +254,7 @@ def export(folder, file, args, texconv=None): def remove_mipmaps(folder, file, args, texconv=None): - '''Remove mode (remove mipmaps from uasset)''' + """Remove mode (remove mipmaps from uasset)""" src_file = os.path.join(folder, file) new_file = os.path.join(args.save_folder, file) asset = Uasset(src_file, version=args.version) @@ -287,28 +287,28 @@ def copy(folder, file, args, texconv=None): def check_version(folder, file, args, texconv=None): - '''Check mode (check file version)''' + """Check mode (check file version)""" - print('Running valid mode with each version...') + print("Running valid mode with each version...") passed_version = [] for ver in UTEX_VERSIONS: try: # try to parse with null stdout - with redirect_stdout(open(os.devnull, 'w')): + with redirect_stdout(open(os.devnull, "w")): valid(folder, file, args, ver.split(" ~ ")[0]) - print(f' {(ver + " " * 11)[:11]}: Passed') + print(f" {(ver + ' ' * 11)[:11]}: Passed") passed_version.append(ver) except Exception: - print(f' {(ver + " " * 11)[:11]}: Failed') + print(f" {(ver + ' ' * 11)[:11]}: Failed") # Show the result. if len(passed_version) == 0: - raise RuntimeError('Failed for all supported versions. You can not mod the asset with this tool.') + raise RuntimeError("Failed for all supported versions. You can not mod the asset with this tool.") elif len(passed_version) == 1 and ("~" not in passed_version[0]): - print(f'The version is {passed_version[0]}.') + print(f"The version is {passed_version[0]}.") else: - s = f'{passed_version}'[1:-1].replace("'", "") - print(f'Found some versions can handle the asset. ({s})') + s = f"{passed_version}"[1:-1].replace("'", "") + print(f"Found some versions can handle the asset. ({s})") if args.save_detected_version: passed_version = [ver.split(" ~ ")[0] for ver in passed_version] @@ -320,7 +320,7 @@ def check_version(folder, file, args, texconv=None): def convert(folder, file, args, texconv=None): - '''Convert mode (convert texture files)''' + """Convert mode (convert texture files)""" src_file = os.path.join(folder, file) new_file = os.path.join(args.save_folder, file) @@ -352,18 +352,30 @@ def convert(folder, file, args, texconv=None): texconv.convert_nondds(src_file, out=os.path.dirname(new_file), fmt=args.convert_to, verbose=False) -def main(args, config={}, texconv=None): +MODE_FUNCTIONS = { + "valid": valid, + "inject": inject, + "remove_mipmaps": remove_mipmaps, + "parse": parse, + "export": export, + "check": check_version, + "convert": convert, + "copy": copy +} + + +def fix_args(args, config): # get config - if (args.version is None) and ('version' in config) and (config['version'] is not None): - args.version = config['version'] + if (args.version is None) and ("version" in config) and (config["version"] is not None): + args.version = config["version"] if args.version is None: - args.version = '4.27' + args.version = "4.27" if args.file.endswith(".txt"): # file path for batch file injection. # you can set an asset path with "echo some_path > some.txt" - with open(args.file, 'r') as f: + with open(args.file, "r") as f: args.file = remove_quotes(f.readline()) if args.mode == "check": @@ -372,47 +384,66 @@ def main(args, config={}, texconv=None): else: if isinstance(args.version, list): args.version = args.version[0] - print(f'UE version: {args.version}') - file = args.file - texture_file = args.texture + +def print_args(args): mode = args.mode + print(f"Mode: {mode}") + + if args.mode != "check": + print(f"UE version: {args.version}") + + if mode == "export": + print(f"Export as: {args.export_as}") + + if mode == "convert": + print(f"Convert to: {args.convert_to}") + + if mode in ["inject", "export"]: + print(f"No mipmaps: {args.no_mipmaps}") + print(f"Skip non textures: {args.skip_non_texture}") - print(f'Mode: {mode}') + if mode == "inject": + print(f"Force uncompressed: {args.force_uncompressed}") + print(f"Image filter: {args.image_filter}") - mode_functions = { - 'valid': valid, - 'inject': inject, - 'remove_mipmaps': remove_mipmaps, - 'parse': parse, - 'export': export, - 'check': check_version, - "convert": convert, - "copy": copy - } - # cehck args +def check_args(args): + texture_file = args.texture + mode = args.mode + if os.path.isfile(args.save_folder): - raise RuntimeError("Output path is not a folder.") - if file == "": - raise RuntimeError("Specify files.") - if mode == 'inject' and (texture_file is None or texture_file == ""): + raise RuntimeError(f"Output path is not a folder. ({args.save_folder})") + if not os.path.exists(args.file): + raise RuntimeError(f"Path not found. ({args.file})") + if mode == "inject" and (texture_file is None or texture_file == ""): raise RuntimeError("Specify texture file.") - if mode not in mode_functions: - raise RuntimeError(f'Unsupported mode. ({mode})') - if mode != 'check' and args.version not in UE_VERSIONS: - raise RuntimeError(f'Unsupported version. ({args.version})') - if args.export_as not in ['tga', 'png', 'dds', 'jpg', 'bmp']: - raise RuntimeError(f'Unsupported format to export. ({args.export_as})') + if mode not in MODE_FUNCTIONS: + raise RuntimeError(f"Unsupported mode. ({mode})") + if mode != "check" and args.version not in UE_VERSIONS: + raise RuntimeError(f"Unsupported version. ({args.version})") + if args.export_as not in ["tga", "png", "dds", "jpg", "bmp"]: + raise RuntimeError(f"Unsupported format to export. ({args.export_as})") if args.image_filter.lower() not in IMAGE_FILTERS: - raise RuntimeError(f'Unsupported image filter. ({args.image_filter})') + raise RuntimeError(f"Unsupported image filter. ({args.image_filter})") + + +def main(args, config={}, texconv=None): + + fix_args(args, config) + print_args(args) + check_args(args) + + file = args.file + texture_file = args.texture + mode = args.mode # load texconv if (mode == "export" and args.export_as != "dds") or mode in ["inject", "convert"]: if texconv is None: texconv = Texconv() - func = mode_functions[mode] + func = MODE_FUNCTIONS[mode] if os.path.isfile(file): folder = os.path.dirname(file) @@ -420,18 +451,19 @@ def main(args, config={}, texconv=None): func(folder, file, args, texconv=texconv) else: # folder method - if mode == 'convert': + if mode == "convert": ext_list = TEXTURES else: - ext_list = ['uasset'] + ext_list = ["uasset"] + # Todo: Use recursive function. No need to make file list anymore. folder, file_list = get_file_list(file, ext=ext_list, include_base=(mode != "inject")) - if mode == 'inject': + if mode == "inject": texture_folder = texture_file if not os.path.isdir(texture_folder): raise RuntimeError( - f'Specified a folder as uasset path. But texture path is not a folder. ({texture_folder})' + f"Specified a folder as uasset path. But texture path is not a folder. ({texture_folder})" ) texture_file_list = [os.path.join(texture_folder, file[:-6] + TEXTURES[0]) for file in file_list] base_folder, folder = get_base_folder(folder) @@ -448,18 +480,18 @@ def main(args, config={}, texconv=None): print("Saved the detected version as src/config.json") if len(args.version) == 1: args.version = args.version[0] - config['version'] = args.version + config["version"] = args.version save_config(config) -if __name__ == '__main__': # pragma: no cover +if __name__ == "__main__": # pragma: no cover start_time = time.time() - print(f'UE4 DDS Tools ver{TOOL_VERSION} by Matyalatte') + print(f"UE4 DDS Tools ver{TOOL_VERSION} by Matyalatte") args = get_args() config = get_config() main(args, config=config) if args.mode != "check": - print(f'Success! Run time (s): {(time.time() - start_time)}') + print(f"Success! Run time (s): {(time.time() - start_time)}") diff --git a/src/unreal/archive.py b/src/unreal/archive.py index 229ad58..7aa78fd 100644 --- a/src/unreal/archive.py +++ b/src/unreal/archive.py @@ -65,12 +65,12 @@ def write(self, obj): def close(self): self.io.close() - def check(self, actual, expected, msg='Parse failed. Make sure you specified UE version correctly.'): + def check(self, actual, expected, msg="Parse failed. Make sure you specified UE version correctly."): if actual == expected: return - print(f'offset: {self.tell()}') - print(f'actual: {actual}') - print(f'expected: {expected}') + print(f"offset: {self.tell()}") + print(f"actual: {actual}") + print(f"expected: {expected}") raise RuntimeError(msg) @@ -216,9 +216,9 @@ def read(ar: ArchiveBase) -> str: utf16 = num < 0 if utf16: num = -num - encode = 'utf-16-le' + encode = "utf-16-le" else: - encode = 'ascii' + encode = "ascii" string = ar.read((num - 1) * (1 + utf16)).decode(encode) ar.seek(1 + utf16, 1) @@ -229,9 +229,9 @@ def write(ar: ArchiveBase, val: str): num = len(val) + 1 utf16 = not val.isascii() Int32.write(ar, num * (1 - 2 * utf16)) - encode = 'utf-16-le' if utf16 else 'ascii' + encode = "utf-16-le" if utf16 else "ascii" str_byte = val.encode(encode) - ar.write(str_byte + b'\x00' * (1 + utf16)) + ar.write(str_byte + b"\x00" * (1 + utf16)) class SerializableBase: diff --git a/src/unreal/crc.py b/src/unreal/crc.py index c19cd1e..4dc5bdd 100644 --- a/src/unreal/crc.py +++ b/src/unreal/crc.py @@ -58,10 +58,10 @@ def memcrc_deprecated(string): """ # Convert string to ints if string.isascii(): - ints = string.upper().encode('ascii') + ints = string.upper().encode("ascii") else: - binary = string.upper().encode('utf-16-le') - ints = struct.unpack('<'+'B'*len(string)*2, binary) + binary = string.upper().encode("utf-16-le") + ints = struct.unpack("<"+"B"*len(string)*2, binary) # Generate hash from ints crc = 0 @@ -84,10 +84,10 @@ def strcrc_deprecated(string): """ # Convert string to ints if string.isascii(): - ints = string.upper().encode('ascii') + ints = string.upper().encode("ascii") else: - binary = string.upper().encode('utf-16-le') - ints = struct.unpack('<'+'H'*len(string), binary) + binary = string.upper().encode("utf-16-le") + ints = struct.unpack("<"+"H"*len(string), binary) # Generate hash from ints crc = 0xFFFFFFFF @@ -149,10 +149,10 @@ def memcrc(string): """ # Convert string to ints if string.isascii(): - ints = string.encode('ascii') + ints = string.encode("ascii") else: - binary = string.encode('utf-16-le') - ints = struct.unpack('<'+'H'*len(string), binary) + binary = string.encode("utf-16-le") + ints = struct.unpack("<"+"H"*len(string), binary) # Generate hash from ints crc = 0xFFFFFFFF @@ -180,5 +180,5 @@ def generate_hash(string): hash1 = memcrc_deprecated(string) hash2 = memcrc(string) hash_int = (hash1 & 0xFFFF) | ((hash2 & 0xFFFF) << 16) - hash_bin = struct.pack('<'+'I', hash_int) + hash_bin = struct.pack("<"+"I", hash_int) return hash_bin diff --git a/src/unreal/uasset.py b/src/unreal/uasset.py index 378a910..23c2678 100644 --- a/src/unreal/uasset.py +++ b/src/unreal/uasset.py @@ -1,4 +1,4 @@ -'''Classes for .uasset''' +"""Classes for .uasset""" from enum import IntEnum import io from io import IOBase @@ -14,14 +14,14 @@ SerializableBase) -EXT = ['.uasset', '.uexp', '.ubulk'] +EXT = [".uasset", ".uexp", ".ubulk"] def get_all_file_path(file: str) -> list[str]: - '''Get all file paths for texture asset from a file path.''' + """Get all file paths for texture asset from a file path.""" base_name, ext = os.path.splitext(file) if ext not in EXT: - raise RuntimeError(f'Not Uasset. ({file})') + raise RuntimeError(f"Not Uasset. ({file})") return [base_name + ext for ext in EXT] @@ -44,8 +44,8 @@ class UassetFileSummary(SerializableBase): Notes: UnrealEngine/Engine/Source/Runtime/CoreUObject/Private/UObject/PackageFileSummary.cpp """ - TAG = b'\xC1\x83\x2A\x9E' # Magic for uasset files - TAG_SWAPPED = b'\x9E\x2A\x83\xC1' # for big endian files + TAG = b"\xC1\x83\x2A\x9E" # Magic for uasset files + TAG_SWAPPED = b"\x9E\x2A\x83\xC1" # for big endian files def serialize(self, ar: ArchiveBase): self.file_name = ar.name @@ -63,8 +63,8 @@ def serialize(self, ar: ArchiveBase): -8: 5.0 ~ """ expected_version = ( - -8 + (ar.version <= '4.6') * 2 + (ar.version <= '4.9') - + (ar.version <= '4.13') + (ar.version <= '4.27') + -8 + (ar.version <= "4.6") * 2 + (ar.version <= "4.9") + + (ar.version <= "4.13") + (ar.version <= "4.27") ) ar == (Int32, expected_version, "header.file_version") @@ -77,7 +77,7 @@ def serialize(self, ar: ArchiveBase): - FileVersionLicenseeUE - CustomVersionContainer """ - ar << (Bytes, self, "version_info", 16 + 4 * (ar.version >= '5.0')) + ar << (Bytes, self, "version_info", 16 + 4 * (ar.version >= "5.0")) ar << (Int32, self, "uasset_size") # TotalHeaderSize ar << (String, self, "package_name") @@ -92,14 +92,14 @@ def serialize(self, ar: ArchiveBase): ar << (Int32, self, "name_count") ar << (Int32, self, "name_offset") - if ar.version >= '5.1': + if ar.version >= "5.1": # SoftObjectPaths ar == (Int32, 0, "soft_object_count") if ar.is_writing: self.soft_object_offset = self.import_offset ar << (Int32, self, "soft_object_offset") - if ar.version >= '4.9': + if ar.version >= "4.9": # GatherableTextData ar == (Int32, 0, "gatherable_text_count") ar == (Int32, 0, "gatherable_text_offset") @@ -115,13 +115,13 @@ def serialize(self, ar: ArchiveBase): # DependsOffset ar << (Int32, self, "depends_offset") - if ar.version >= '4.4' and ar.version <= '4.14': + if ar.version >= "4.4" and ar.version <= "4.14": # StringAssetReferencesCount ar == (Int32, 0, "string_asset_count") if ar.is_writing: self.string_asset_offset = self.asset_registry_data_offset ar << (Int32, self, "string_asset_offset") - elif ar.version >= '4.15': + elif ar.version >= "4.15": # SoftPackageReferencesCount ar == (Int32, 0, "soft_package_count") ar == (Int32, 0, "soft_package_offset") @@ -144,7 +144,7 @@ def serialize(self, ar: ArchiveBase): - SavedByEngineVersion (14 bytes) - CompatibleWithEngineVersion (14 bytes) (4.8 ~ ) """ - ar << (Bytes, self, "empty_engine_version", 14 * (1 + (ar.version >= '4.8'))) + ar << (Bytes, self, "empty_engine_version", 14 * (1 + (ar.version >= "4.8"))) # CompressionFlags, CompressedChunks ar << (Bytes, self, "compression_info", 8) @@ -159,7 +159,7 @@ def serialize(self, ar: ArchiveBase): # AdditionalPackagesToCook (zero length array) ar == (Int32, 0, "additional_packages_to_cook") - if ar.version <= '4.13': + if ar.version <= "4.13": ar == (Int32, 0, "num_texture_allocations") ar << (Int32, self, "asset_registry_data_offset") ar << (Int32, self, "bulk_offset") # .uasset + .uexp - 4 (BulkDataStartOffset) @@ -170,14 +170,14 @@ def serialize(self, ar: ArchiveBase): # ChunkIDs (zero length array), ChunkID ar == (Int32Array, [0, 0], "ChunkID", 2) - if ar.version <= '4.13': + if ar.version <= "4.13": return # PreloadDependency ar << (Int32, self, "preload_dependency_count") ar << (Int32, self, "preload_dependency_offset") - if ar.version <= '4.27': + if ar.version <= "4.27": return # Number of names that are referenced from serialized export data @@ -187,17 +187,17 @@ def serialize(self, ar: ArchiveBase): ar << (Int64, self, "payload_toc_offset") def print(self): - print('File Summary') - print(f' file size: {self.uasset_size}') - print(f' number of names: {self.name_count}') - print(' name directory offset: 193') - print(f' number of exports: {self.export_count}') - print(f' export directory offset: {self.export_offset}') - print(f' number of imports: {self.import_count}') - print(f' import directory offset: {self.import_offset}') - print(f' depends offset: {self.depends_offset}') - print(f' file length (uasset+uexp-4): {self.bulk_offset}') - print(f' official asset: {self.is_official()}') + print("File Summary") + print(f" file size: {self.uasset_size}") + print(f" number of names: {self.name_count}") + print(" name directory offset: 193") + print(f" number of exports: {self.export_count}") + print(f" export directory offset: {self.export_offset}") + print(f" number of imports: {self.import_count}") + print(f" import directory offset: {self.import_offset}") + print(f" depends offset: {self.depends_offset}") + print(f" file length (uasset+uexp-4): {self.bulk_offset}") + print(f" official asset: {self.is_official()}") print(f" unversioned: {self.is_unversioned()}") def is_unversioned(self): @@ -258,7 +258,7 @@ def serialize(self, ar: ArchiveBase): ar << (Int32, self, "class_package_import_id") ar << (Int32, self, "name_id") ar << (Int32, self, "name_number") - if ar.version >= '5.0': + if ar.version >= "5.0": ar << (Uint32, self, "optional") def name_import(self, name_list: list[Name]) -> str: @@ -268,10 +268,10 @@ def name_import(self, name_list: list[Name]) -> str: return self.name def print(self, padding=2): - pad = ' ' * padding + pad = " " * padding print(pad + self.name) - print(pad + ' class: ' + self.class_name) - print(pad + ' class_file: ' + self.class_package_name) + print(pad + " class: " + self.class_name) + print(pad + " class_file: " + self.class_package_name) class Uunknown(SerializableBase): @@ -304,14 +304,14 @@ def __init__(self): def serialize(self, ar: ArchiveBase): ar << (Int32, self, "class_import_id") - if ar.version >= '4.14': + if ar.version >= "4.14": ar << (Int32, self, "template_index") ar << (Int32, self, "super_import_id") ar << (Int32, self, "outer_index") # 0: main object, 1: not main ar << (Int32, self, "name_id") ar << (Int32, self, "name_number") ar << (Uint32, self, "object_flags") # & 8: main object - if ar.version <= '4.15': + if ar.version <= "4.15": ar << (Uint32, self, "size") else: ar << (Uint64, self, "size") @@ -324,12 +324,12 @@ def serialize(self, ar: ArchiveBase): @staticmethod def get_remainings_size(version: VersionInfo) -> int: sizes = [ - ['4.2', 32], - ['4.10', 36], - ['4.13', 40], - ['4.15', 60], - ['4.27', 64], - ['5.0', 68] + ["4.2", 32], + ["4.10", 36], + ["4.13", 40], + ["4.15", 60], + ["4.27", 64], + ["5.0", 68] ] for ver, size in sizes: if version <= ver: @@ -340,9 +340,9 @@ def get_remainings_size(version: VersionInfo) -> int: @staticmethod def get_meta_size(version: VersionInfo): meta_size = 32 - if version >= '4.14': + if version >= "4.14": meta_size += 4 - if version >= '4.16': + if version >= "4.16": meta_size += 4 meta_size += UassetExport.get_remainings_size(version) return meta_size @@ -370,38 +370,38 @@ def is_texture(self): return self.class_name in UassetExport.TEXTURE_CLASSES def print(self, padding=2): - pad = ' ' * padding - print(pad + f'{self.name}') - print(pad + f' class: {self.class_name}') - print(pad + f' super: {self.super_name}') - print(pad + f' size: {self.size}') - print(pad + f' offset: {self.offset}') - print(pad + f' is public: {self.is_public()}') - print(pad + f' is standalone: {self.is_standalone()}') - print(pad + f' is base: {self.is_base()}') - print(pad + f' object flags: {self.object_flags}') + pad = " " * padding + print(pad + f"{self.name}") + print(pad + f" class: {self.class_name}") + print(pad + f" super: {self.super_name}") + print(pad + f" size: {self.size}") + print(pad + f" offset: {self.offset}") + print(pad + f" is public: {self.is_public()}") + print(pad + f" is standalone: {self.is_standalone()}") + print(pad + f" is base: {self.is_base()}") + print(pad + f" object flags: {self.object_flags}") class Uasset: def __init__(self, file_path: str, version: str = "ff7r", verbose=False): if not os.path.isfile(file_path): - raise RuntimeError(f'Not File. ({file_path})') + raise RuntimeError(f"Not File. ({file_path})") self.texture = None self.uexp_io = None self.ubulk_io = None self.uasset_file, self.uexp_file, self.ubulk_file = get_all_file_path(file_path) - print('load: ' + self.uasset_file) + print("load: " + self.uasset_file) - if self.uasset_file[-7:] != '.uasset': - raise RuntimeError(f'Not .uasset. ({self.uasset_file})') + if self.uasset_file[-7:] != ".uasset": + raise RuntimeError(f"Not .uasset. ({self.uasset_file})") if verbose: - print('Loading ' + self.uasset_file + '...') + print("Loading " + self.uasset_file + "...") self.version = VersionInfo(version) self.context = {"version": self.version, "verbose": verbose, "valid": False} - ar = ArchiveRead(open(self.uasset_file, 'rb'), context=self.context) + ar = ArchiveRead(open(self.uasset_file, "rb"), context=self.context) self.serialize(ar) ar.close() self.read_export_objects(verbose=verbose) @@ -418,9 +418,9 @@ def serialize(self, ar: ArchiveBase): # read name map ar << (StructArray, self, "name_list", Name, self.header.name_count) if ar.verbose: - print('Names') + print("Names") for i, name in zip(range(len(self.name_list)), self.name_list): - print(' {}: {}'.format(i, name)) + print(" {}: {}".format(i, name)) # read imports if ar.is_reading: @@ -431,7 +431,7 @@ def serialize(self, ar: ArchiveBase): if ar.is_reading: list(map(lambda x: x.name_import(self.name_list), self.imports)) if ar.verbose: - print('Imports') + print("Imports") list(map(lambda x: x.print(), self.imports)) if ar.is_reading: @@ -440,14 +440,14 @@ def serialize(self, ar: ArchiveBase): ar << (StructArray, self, "exports", UassetExport, self.header.export_count) list(map(lambda x: x.name_export(self.imports, self.name_list), self.exports)) if ar.verbose: - print('Exports') + print("Exports") list(map(lambda x: x.print(), self.exports)) - print(f'Main Export Class: {self.get_main_class_name()}') + print(f"Main Export Class: {self.get_main_class_name()}") else: # skip exports part self.header.export_offset = ar.tell() ar.seek(UassetExport.get_meta_size(ar.version) * self.header.export_count, 1) - if self.version not in ['4.15', '4.14']: + if self.version not in ["4.15", "4.14"]: self.header.depends_offset = ar.tell() # read depends map @@ -529,7 +529,7 @@ def write_export_objects(self): def save(self, file: str, valid=False): folder = os.path.dirname(file) - if folder not in ['.', ''] and not os.path.exists(folder): + if folder not in [".", ""] and not os.path.exists(folder): mkdir(folder) self.uasset_file, self.uexp_file, self.ubulk_file = get_all_file_path(file) @@ -537,12 +537,12 @@ def save(self, file: str, valid=False): if not self.has_ubulk(): self.ubulk_file = None - print('save :' + self.uasset_file) + print("save :" + self.uasset_file) self.context = {"version": self.version, "verbose": False, "valid": valid} self.write_export_objects() - ar = ArchiveWrite(open(self.uasset_file, 'wb'), context=self.context) + ar = ArchiveWrite(open(self.uasset_file, "wb"), context=self.context) self.serialize(ar) @@ -577,7 +577,7 @@ def get_main_class_name(self): return main_obj.class_name def has_uexp(self): - return self.version >= '4.16' + return self.version >= "4.16" def has_ubulk(self): for exp in self.exports: @@ -602,7 +602,7 @@ def __get_io(self, file: str, bin: bytes, rb: bool) -> IOBase: if self.has_uexp(): opened_io = open(file, "rb" if rb else "wb") else: - opened_io = io.BytesIO(bin if rb else b'') + opened_io = io.BytesIO(bin if rb else b"") if rb: return ArchiveRead(opened_io, context=self.context) diff --git a/src/unreal/umipmap.py b/src/unreal/umipmap.py index b0d6c74..db5b631 100644 --- a/src/unreal/umipmap.py +++ b/src/unreal/umipmap.py @@ -1,4 +1,4 @@ -'''Mipmap class for texture asset''' +"""Mipmap class for texture asset""" from enum import IntEnum from .archive import (ArchiveBase, Int64, Uint32, Uint16, Buffer, SerializableBase) @@ -60,21 +60,21 @@ def serialize(self, ar: ArchiveBase): ar << (Uint32, self, "data_size") ar == (Uint32, self.data_size, "data_size2") if ar.is_writing: - self.offset_to_offset = ar.tell() if self.is_uexp: self.offset += ar.tell() + 8 + self.offset_to_offset = ar.tell() ar << (Int64, self, "offset") if self.is_uexp and not self.is_meta: ar << (Buffer, self, "data", self.data_size) - if ar.version == 'borderlands3': + if ar.version == "borderlands3": int_type = Uint16 else: int_type = Uint32 ar << (int_type, self, "width") ar << (int_type, self, "height") - if ar.version >= '4.20': + if ar.version >= "4.20": ar << (int_type, self, "depth") self.pixel_num = self.width * self.height * self.depth @@ -96,7 +96,7 @@ def __unpack_ubulk_flags(self): self.is_meta = self.ubulk_flags & BulkDataFlags.BULKDATA_Unused > 0 self.is_upntl = self.ubulk_flags & BulkDataFlags.BULKDATA_OptionalPayload > 0 if self.is_upntl: - raise RuntimeError("Optional payload (.is_upntl) is unsupported.") + raise RuntimeError("Optional payload (.upntl) is unsupported.") def __update_ubulk_flags(self): # update bulk flags @@ -105,23 +105,23 @@ def __update_ubulk_flags(self): self.ubulk_flags = BulkDataFlags.BULKDATA_Unused else: self.ubulk_flags = BulkDataFlags.BULKDATA_ForceInlinePayload - if self.version != 'ff7r': + if self.version != "ff7r": self.ubulk_flags |= BulkDataFlags.BULKDATA_SingleUse else: self.ubulk_flags = BulkDataFlags.BULKDATA_PayloadAtEndOfFile - if self.version >= '4.14': + if self.version >= "4.14": self.ubulk_flags |= BulkDataFlags.BULKDATA_Force_NOT_InlinePayload - if self.version >= '4.16': + if self.version >= "4.16": self.ubulk_flags |= BulkDataFlags.BULKDATA_PayloadInSeperateFile - if (self.version == 'ff7r') or (self.version >= '4.26'): + if (self.version == "ff7r") or (self.version >= "4.26"): self.ubulk_flags |= BulkDataFlags.BULKDATA_NoOffsetFixUp def print(self, padding: int = 2): - pad = ' ' * padding - print(pad + 'file: ' + 'uexp' * self.is_uexp + 'ubluk' * (not self.is_uexp)) - print(pad + f'data size: {self.data_size}') - print(pad + f'offset: {self.offset}') - print(pad + f'width: {self.width}') - print(pad + f'height: {self.height}') - if self.version >= '4.20' and self.depth > 1: - print(pad + f'depth: {self.depth}') + pad = " " * padding + print(pad + "file: " + "uexp" * self.is_uexp + "ubluk" * (not self.is_uexp)) + print(pad + f"data size: {self.data_size}") + print(pad + f"offset: {self.offset}") + print(pad + f"width: {self.width}") + print(pad + f"height: {self.height}") + if self.version >= "4.20" and self.depth > 1: + print(pad + f"depth: {self.depth}") diff --git a/src/unreal/utexture.py b/src/unreal/utexture.py index b566064..7aadbc3 100644 --- a/src/unreal/utexture.py +++ b/src/unreal/utexture.py @@ -1,4 +1,4 @@ -'''Classes for texture assets (.uexp and .ubulk)''' +"""Classes for texture assets (.uexp and .ubulk)""" from .umipmap import Umipmap from .version import VersionInfo from directx.dds import DDSHeader, DDS @@ -7,40 +7,40 @@ # Defined in UnrealEngine/Engine/Source/Runtime/D3D12RHI/Private/D3D12RHI.cpp PF_TO_DXGI = { - 'PF_DXT1': DXGI_FORMAT.BC1_UNORM, - 'PF_DXT3': DXGI_FORMAT.BC2_UNORM, - 'PF_DXT5': DXGI_FORMAT.BC3_UNORM, - 'PF_BC4': DXGI_FORMAT.BC4_UNORM, - 'PF_BC5': DXGI_FORMAT.BC5_UNORM, - 'PF_BC6H': DXGI_FORMAT.BC6H_UF16, - 'PF_BC7': DXGI_FORMAT.BC7_UNORM, - 'PF_A1': DXGI_FORMAT.R1_UNORM, - 'PF_A8': DXGI_FORMAT.A8_UNORM, - 'PF_G8': DXGI_FORMAT.R8_UNORM, - 'PF_R8': DXGI_FORMAT.R8_UNORM, - 'PF_R8G8': DXGI_FORMAT.R8G8_UNORM, - 'PF_G16': DXGI_FORMAT.R16_UNORM, - 'PF_G16R16': DXGI_FORMAT.R16G16_UNORM, - 'PF_B8G8R8A8': DXGI_FORMAT.B8G8R8A8_UNORM, - 'PF_A2B10G10R10': DXGI_FORMAT.R10G10B10A2_UNORM, - 'PF_A16B16G16R16': DXGI_FORMAT.R16G16B16A16_UNORM, - 'PF_FloatRGB': DXGI_FORMAT.R11G11B10_FLOAT, - 'PF_FloatR11G11B10': DXGI_FORMAT.R11G11B10_FLOAT, - 'PF_FloatRGBA': DXGI_FORMAT.R16G16B16A16_FLOAT, - 'PF_A32B32G32R32F': DXGI_FORMAT.R32G32B32A32_FLOAT, - 'PF_B5G5R5A1_UNORM': DXGI_FORMAT.B5G5R5A1_UNORM, - 'PF_ASTC_4x4': DXGI_FORMAT.ASTC_4X4_UNORM + "PF_DXT1": DXGI_FORMAT.BC1_UNORM, + "PF_DXT3": DXGI_FORMAT.BC2_UNORM, + "PF_DXT5": DXGI_FORMAT.BC3_UNORM, + "PF_BC4": DXGI_FORMAT.BC4_UNORM, + "PF_BC5": DXGI_FORMAT.BC5_UNORM, + "PF_BC6H": DXGI_FORMAT.BC6H_UF16, + "PF_BC7": DXGI_FORMAT.BC7_UNORM, + "PF_A1": DXGI_FORMAT.R1_UNORM, + "PF_A8": DXGI_FORMAT.A8_UNORM, + "PF_G8": DXGI_FORMAT.R8_UNORM, + "PF_R8": DXGI_FORMAT.R8_UNORM, + "PF_R8G8": DXGI_FORMAT.R8G8_UNORM, + "PF_G16": DXGI_FORMAT.R16_UNORM, + "PF_G16R16": DXGI_FORMAT.R16G16_UNORM, + "PF_B8G8R8A8": DXGI_FORMAT.B8G8R8A8_UNORM, + "PF_A2B10G10R10": DXGI_FORMAT.R10G10B10A2_UNORM, + "PF_A16B16G16R16": DXGI_FORMAT.R16G16B16A16_UNORM, + "PF_FloatRGB": DXGI_FORMAT.R11G11B10_FLOAT, + "PF_FloatR11G11B10": DXGI_FORMAT.R11G11B10_FLOAT, + "PF_FloatRGBA": DXGI_FORMAT.R16G16B16A16_FLOAT, + "PF_A32B32G32R32F": DXGI_FORMAT.R32G32B32A32_FLOAT, + "PF_B5G5R5A1_UNORM": DXGI_FORMAT.B5G5R5A1_UNORM, + "PF_ASTC_4x4": DXGI_FORMAT.ASTC_4X4_UNORM } PF_TO_UNCOMPRESSED = { - 'PF_DXT1': 'PF_B8G8R8A8', - 'PF_DXT3': 'PF_B8G8R8A8', - 'PF_DXT5': 'PF_B8G8R8A8', - 'PF_BC4': 'PF_G8', - 'PF_BC5': 'PF_R8G8', - 'PF_BC6H': 'PF_FloatRGBA', - 'PF_BC7': 'PF_B8G8R8A8', - 'PF_ASTC_4x4': 'PF_B8G8R8A8' + "PF_DXT1": "PF_B8G8R8A8", + "PF_DXT3": "PF_B8G8R8A8", + "PF_DXT5": "PF_B8G8R8A8", + "PF_BC4": "PF_G8", + "PF_BC5": "PF_R8G8", + "PF_BC6H": "PF_FloatRGBA", + "PF_BC7": "PF_B8G8R8A8", + "PF_ASTC_4x4": "PF_B8G8R8A8" } @@ -110,11 +110,11 @@ def __calculate_prop_size(self, ar: ArchiveBase): Just searching x01 is not the best algorithm but fast enough. Because "found 01" means "found strip flags" for most texture assets. """ - while (ar.read(1) != b'\x01'): + while (ar.read(1) != b"\x01"): if (ar.tell() >= err_offset): - raise RuntimeError('Parse Failed. Make sure you specified UE4 version correctly.') + raise RuntimeError("Parse Failed. Make sure you specified UE4 version correctly.") - if ar.read(7) == b'\x00\x01\x00\x01\x00\x00\x00': + if ar.read(7) == b"\x00\x01\x00\x01\x00\x00\x00": # Found \x01\x00\x01\x00\x01\x00\x00\x00 break else: @@ -136,9 +136,9 @@ def __serialize_uexp(self, ar: ArchiveBase, ubulk_start_offset: int = 0): ar << (Uint64, self, "pixel_format_name_id") self.skip_offset_location = ar.tell() # offset to self.skip_offset ar << (Uint32, self, "skip_offset") # Offset to the end of this object - if ar.version >= '4.20': + if ar.version >= "4.20": ar == (Uint32, 0, "?") - if ar.version >= '5.0': + if ar.version >= "5.0": ar << (Bytes, self, "placeholder", 16) # FTexturePlatformData::SerializeCooked (SerializePlatformData) @@ -158,7 +158,7 @@ def __serialize_uexp(self, ar: ArchiveBase, ubulk_start_offset: int = 0): self.__unpack_packed_data() self.__update_format() - if self.version == 'ff7r' and self.has_opt_data: + if self.version == "ff7r" and self.has_opt_data: ar == (Uint32, 0, "?") ar == (Uint32, 0, "?") if ar.is_writing: @@ -168,15 +168,15 @@ def __serialize_uexp(self, ar: ArchiveBase, ubulk_start_offset: int = 0): ar << (Uint32, self, "first_mip_to_serialize") ar << (Uint32, self, "mip_count") - if self.version == 'ff7r': + if self.version == "ff7r": # ff7r have all mipmap data in a mipmap object if ar.is_writing: - uexp_bulk = b'' + uexp_bulk = b"" for mip in self.mipmaps: if mip.is_uexp: mip.is_meta = True mip.data_size = 0 - uexp_bulk = b''.join([uexp_bulk, mip.data]) + uexp_bulk = b"".join([uexp_bulk, mip.data]) size = self.get_max_uexp_size() self.uexp_optional_mip.update(uexp_bulk, size, 1, True) ar << (Umipmap, self, "uexp_optional_mip", uasset_size) @@ -197,11 +197,11 @@ def __serialize_uexp(self, ar: ArchiveBase, ubulk_start_offset: int = 0): _, ubulk_map_num = self.get_mipmap_num() self.has_ubulk = ubulk_map_num > 0 - if ar.version >= '4.23': + if ar.version >= "4.23": ar == (Uint32, 0, "bIsVirtual") if ar.is_writing: - if ar.version >= '5.0': + if ar.version >= "5.0": self.skip_offset = ar.tell() - self.skip_offset_location else: self.skip_offset = ar.tell() + uasset_size @@ -214,7 +214,7 @@ def __serialize_uexp(self, ar: ArchiveBase, ubulk_start_offset: int = 0): self.uexp_size = ar.tell() - start_offset if ar.is_reading: - if self.version == 'ff7r' and self.has_supported_format(): + if self.version == "ff7r" and self.has_supported_format(): # split mipmap data offset = 0 for mip in self.mipmaps: @@ -250,9 +250,9 @@ def get_mipmap_num(self) -> tuple[int, int]: return uexp_map_num, ubulk_map_num def rewrite_offset_data(self): - if self.version <= '4.15' or self.version >= '4.26' or self.version == 'ff7r': + if self.version <= "4.15" or self.version >= "4.26" or self.version == "ff7r": return - # ubulk mipmaps have wierd offset data. (Fixed at 4.26) + # ubulk mipmaps have wierd offset data for old UE versions. (Fixed at 4.26) f = self.uasset.get_uexp_io(rb=False) uasset_size = self.uasset.get_size() uexp_size = self.uasset.get_uexp_size() @@ -268,13 +268,13 @@ def remove_mipmaps(self): self.mipmaps = [self.mipmaps[0]] self.mipmaps[0].is_uexp = True self.has_ubulk = False - print('mipmaps have been removed.') - print(f' mipmap: {old_mipmap_num} -> 1') + print("mipmaps have been removed.") + print(f" mipmap: {old_mipmap_num} -> 1") def get_dds(self) -> DDS: """Get texture as dds.""" if not self.has_supported_format(): - raise RuntimeError(f'Unsupported pixel format. ({self.pixel_format})') + raise RuntimeError(f"Unsupported pixel format. ({self.pixel_format})") # make dds header header = DDSHeader() @@ -289,11 +289,11 @@ def get_dds(self) -> DDS: # mip list to slice list for i in range(self.num_slices): - data = b'' + data = b"" for mip in self.mipmaps: size = int(mip.width * mip.height * self.byte_per_pixel) offset = size * i - data = b''.join([data, mip.data[offset: offset + size]]) + data = b"".join([data, mip.data[offset: offset + size]]) slice_bin_list.append(data) mipmap_size_list.append([mip.width, mip.height]) @@ -302,7 +302,7 @@ def get_dds(self) -> DDS: def inject_dds(self, dds: DDS): """Inject dds into asset.""" if not self.has_supported_format(): - raise RuntimeError(f'Unsupported pixel format. ({self.pixel_format})') + raise RuntimeError(f"Unsupported pixel format. ({self.pixel_format})") # check formats if dds.header.dxgi_format != self.dxgi_format: @@ -337,10 +337,10 @@ def inject_dds(self, dds: DDS): offset = 0 for size, mip, i in zip(dds.mipmap_size_list, self.mipmaps, range(len(self.mipmaps))): # get a mip data from slices - data = b'' + data = b"" bin_size = int(size[0] * size[1] * self.byte_per_pixel) for slice_bin in dds.slice_bin_list: - data = b''.join([data, slice_bin[offset: offset + bin_size]]) + data = b"".join([data, slice_bin[offset: offset + bin_size]]) offset += bin_size if self.has_ubulk and i + 1 < len(self.mipmaps) and size[0] * size[1] > uexp_width * uexp_height: mip.update(data, size, new_depth, False) @@ -356,38 +356,38 @@ def inject_dds(self, dds: DDS): if self.version == "ff7r": self.has_opt_data = self.has_ubulk new_mipmap_num = len(self.mipmaps) - print('DDS has been injected.') - print(f' size: {old_size} -> {new_size}') + print("DDS has been injected.") + print(f" size: {old_size} -> {new_size}") if self.is_3d: - print(f' depth: {old_depth} -> {new_depth}') + print(f" depth: {old_depth} -> {new_depth}") else: - print(f' mipmap: {old_mipmap_num} -> {new_mipmap_num}') + print(f" mipmap: {old_mipmap_num} -> {new_mipmap_num}") # warnings if new_mipmap_num > 1 and (not is_power_of_2(max_width) or not is_power_of_2(max_height)): - print(f'Warning: Mipmaps should have power of 2 as its width and height. ({max_width}, {max_height})') + print(f"Warning: Mipmaps should have power of 2 as its width and height. ({max_width}, {max_height})") if new_mipmap_num > 1 and old_mipmap_num == 1: - print('Warning: The original texture has only 1 mipmap. But your dds has multiple mipmaps.') + print("Warning: The original texture has only 1 mipmap. But your dds has multiple mipmaps.") def print(self, verbose=False): if verbose: i = 0 for mip in self.mipmaps: - print(f' Mipmap {i}') + print(f" Mipmap {i}") mip.print(padding=4) i += 1 max_width, max_height = self.get_max_size() depth = self.get_depth() - print(f' type: {self.get_texture_type()}') - print(f' format: {self.pixel_format} ({self.dxgi_format.name})') - print(f' width: {max_width}') - print(f' height: {max_height}') + print(f" type: {self.get_texture_type()}") + print(f" format: {self.pixel_format} ({self.dxgi_format.name})") + print(f" width: {max_width}") + print(f" height: {max_height}") if self.is_3d: - print(f' depth: {depth}') + print(f" depth: {depth}") elif self.is_array: - print(f' array_size: {self.get_array_size()}') + print(f" array_size: {self.get_array_size()}") else: - print(f' mipmaps: {len(self.mipmaps)}') + print(f" mipmaps: {len(self.mipmaps)}") def to_uncompressed(self): if self.pixel_format in PF_TO_UNCOMPRESSED: @@ -396,7 +396,7 @@ def to_uncompressed(self): def change_format(self, pixel_format: str): """Change pixel format.""" if self.pixel_format != pixel_format: - print(f'Changed pixel format from {self.pixel_format} to {pixel_format}') + print(f"Changed pixel format from {self.pixel_format} to {pixel_format}") self.pixel_format = pixel_format self.__update_format() self.uasset.update_name_list(self.pixel_format_name_id, pixel_format) @@ -406,7 +406,7 @@ def has_supported_format(self): def __update_format(self): if not self.has_supported_format(): - print(f'Warning: Unsupported pixel format. ({self.pixel_format})') + print(f"Warning: Unsupported pixel format. ({self.pixel_format})") self.dxgi_format = DXGI_FORMAT.UNKNOWN self.byte_per_pixel = None return @@ -414,14 +414,14 @@ def __update_format(self): self.byte_per_pixel = DXGI_BYTE_PER_PIXEL[self.dxgi_format] def __unpack_packed_data(self): - if self.version >= "4.24" or self.version == 'ff7r': + if self.version >= "4.24" or self.version == "ff7r": # self.is_cube = packed_data & (1 << 31) > 0 self.has_opt_data = self.packed_data & (1 << 30) > 0 self.num_slices = self.packed_data & ((1 << 30) - 1) def __update_packed_data(self) -> int: self.packed_data = self.num_slices - if self.version >= "4.24" or self.version == 'ff7r': + if self.version >= "4.24" or self.version == "ff7r": self.packed_data |= self.is_cube * (1 << 31) self.packed_data |= self.has_opt_data * (1 << 30) diff --git a/src/unreal/version.py b/src/unreal/version.py index e229e7f..447b36a 100644 --- a/src/unreal/version.py +++ b/src/unreal/version.py @@ -20,11 +20,11 @@ class VersionInfo: def __init__(self, version: str, base_int: int = None): """Constractor.""" - if version == 'ff7r': - base = '4.18' + if version == "ff7r": + base = "4.18" custom = version - elif version == 'borderlands3': - base = '4.22' + elif version == "borderlands3": + base = "4.22" custom = version else: base = version @@ -68,7 +68,7 @@ def __str__(self): # str(self) def version_as_int(ver: str): # ver (string): like "x.x.x" """Convert a string to int.""" - ver_str = [int(s) for s in ver.split('.')] + ver_str = [int(s) for s in ver.split(".")] if len(ver_str) > 3: - raise RuntimeError(f'Unsupported version info.({ver})') + raise RuntimeError(f"Unsupported version info.({ver})") return sum(s * (10 ** ((2 - i) * 2)) for s, i in zip(ver_str, range(len(ver_str)))) diff --git a/src/util.py b/src/util.py index 0b68016..07113f6 100644 --- a/src/util.py +++ b/src/util.py @@ -43,9 +43,9 @@ def get_size(f: IOBase): def compare(file1: str, file2: str): - f1 = open(file1, 'rb') - f2 = open(file2, 'rb') - print(f'Comparing {file1} and {file2}...') + f1 = open(file1, "rb") + f2 = open(file2, "rb") + print(f"Comparing {file1} and {file2}...") f1_size = get_size(f1) f2_size = get_size(f2) @@ -56,7 +56,7 @@ def compare(file1: str, file2: str): f2.close() if f1_size == f2_size and f1_bin == f2_bin: - print('Same data!') + print("Same data!") return i = 0 @@ -65,7 +65,7 @@ def compare(file1: str, file2: str): break i += 1 - raise RuntimeError(f'Not same :{i}') + raise RuntimeError(f"Not same :{i}") def remove_quotes(string: str) -> str: