diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcb7b01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,120 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/README.md b/README.md index 58bf840..1648d34 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,18 @@ Converting VTF images wasn't as easy before 2. Click **Update** button, so folder path would appear in white box 3. Choose all folder paths you want to be processed 4. Choose version you want them to be converted to -5. Click **Convert** +5. Click **Convert** + +### Building +Install dependencies `pip install -r requirements.txt` and run `python buildexe.py` +You will then find **VTFChanger.exe** inside _dist_ folder + +### Testing +For pytest to work properly, link current project as module package `pip install -e .` +Tests rely on VTFCmd.exe for validation, so you will have to install it if you haven't already (it comes with VTFEdit), +can be downloaded here https://github.com/NeilJed/VTFLib/releases +Replace VTFCMD_path value with absolute path to the VTFCmd.exe in **.../tests/test_main.py:23** +```python +self.VTFCMD_path = '' +``` +Then you can run `pytest` or `py.test` for executing tests diff --git a/buildexe.py b/buildexe.py new file mode 100644 index 0000000..8bbbbf3 --- /dev/null +++ b/buildexe.py @@ -0,0 +1,31 @@ +import os +import shutil + +import PyInstaller.__main__ + +from pathlib import Path + + +exe_name = 'VTFChanger' +root_folder = Path(__file__).parent + +PyInstaller.__main__.run([ + '--onefile', + '--noconsole', + '--clean', + '-n', + exe_name, + 'src/gui.py' +]) + + +# cleanup +try: + os.remove(f'{root_folder}\\{exe_name}.spec') +except OSError as e: + print(f'Error: {e}') + +try: + shutil.rmtree(f'{root_folder}\\build') +except OSError as e: + print(f'Error: {e}') diff --git a/c_structs.py b/c_structs.py deleted file mode 100644 index 3e04ceb..0000000 --- a/c_structs.py +++ /dev/null @@ -1,50 +0,0 @@ -from struct import pack, unpack - - -class c_uint32(): - def __init__(self, bytes): - self.value = unpack(' 0: - start_time = time() - textures_paths = find_textures([path_selector.get(sel_path_idx) for sel_path_idx in sel_path_idxs]) - progress["value"] = 0 - - writeToConsole("Found %s texture(s) in %s directory(ies)" % (len(textures_paths), len(sel_path_idxs))) - writeToConsole("Converting to VTF version %s" % vervar.get()) - - for i in range(len(textures_paths)): - tex_path = textures_paths[i] - convert(tex_path, vervar.get()) - progress["value"] += 100 / len(textures_paths) - window.update() - - execution_time = round(time() - start_time, 2) - writeToConsole("Done in %s seconds!" % execution_time) - elif path_selector.size() == 0: - writeToConsole("You don't have any directories open!") - else: - writeToConsole("You haven't selected any paths!") - -def update_explorer_data(msg=True): - clearConsole() - path_selector.delete(0, END) - explorer_paths = getExplorerWindowPaths() - - for path in explorer_paths: - path_selector.insert(END, path) - - if msg: writeToConsole("Updated!") - -if OS_TYPE in supported_os: - # Main software window and frame setup - window = Tk() - window.geometry('500x275') - window.resizable(False, False) - window.title("Easy VTF Converter %s" % version) - - mainframe = Frame() - mainframe.pack(padx=8, pady=8) - mainframe.grid_rowconfigure(0, weight=1) - mainframe.grid_columnconfigure(0, weight=1) - - # Left part of the window (the one with selection box) - path_selector = Listbox(mainframe, selectmode="multiple", width=60) - path_selector.grid(row=0, column=0, rowspan=5) - path_selector.config(activestyle="none", background="#fefefe", fg="#000000", relief=SOLID) - - yscrollbar = Scrollbar(mainframe, orient="vertical") - yscrollbar.config(command=path_selector.yview) - yscrollbar.grid(row=0, column=1, rowspan=5, sticky=N+S) - - xscrollbar = Scrollbar(mainframe, orient="horizontal") - xscrollbar.config(command=path_selector.xview) - xscrollbar.grid(row=5, column=0, sticky=E+W) - - path_selector.config(yscrollcommand=yscrollbar.set, xscrollcommand=xscrollbar.set) - - # Right part of the window (buttons, selectors) - versions = ['7.0', '7.1', '7.2', '7.3', '7.4', '7.5'] - vervar = StringVar(mainframe) - vervar.set('7.2') - - # Version selector - Label(mainframe, text="Convert to").grid(row=0, column=2) - vselector = OptionMenu(mainframe, vervar, *versions) - vselector.config(width=2, relief=SOLID, bd=1, fg="#000acc") - vselector.grid(row=0, column=3, sticky=E+W) - - # Button's *onhover* handlers - def ud_hover_in(e): update_btn['background'] = '#edfaff' - def ud_hover_out(e): update_btn['background'] = 'SystemButtonFace' - def cv_hover_in(e): convert_btn['background'] = '#edfaff' - def cv_hover_out(e): convert_btn['background'] = 'SystemButtonFace' - - # Buttons - update_btn = Button(mainframe, padx=6, text="Update", command=update_explorer_data) - update_btn.config(relief=SOLID, bd=1, fg="#000acc") - update_btn.grid(row=2, column=2, columnspan=2, pady=8, sticky=E+W) - update_btn.bind("", ud_hover_in) - update_btn.bind("", ud_hover_out) - - convert_btn = Button(mainframe, padx=6, text="Convert", command=convert_textures) - convert_btn.config(relief=SOLID, bd=1, fg="#000acc", font=("TkDefaultFont", 10, "bold")) - convert_btn.grid(row=3, column=2, columnspan=2, sticky=E+W) - convert_btn.bind("", cv_hover_in) - convert_btn.bind("", cv_hover_out) - - # Bottom part of the window (console and progress bar) - console = Text(mainframe, relief=SUNKEN, background="gray85", foreground="#000000") - console.config(height=6) - console.grid(row=6, column=0, columnspan=4, pady=6, sticky=E+W) - - progress = Progressbar(mainframe, orient="horizontal", mode="determinate") - progress.grid(row=7, column=0, columnspan=4, sticky=E+W) - progress["maximum"] = 100 - progress["value"] = 0 - - # adding choices to the selection box - update_explorer_data(False) - - window.mainloop() - -else: - window = Tk() - window.withdraw() - showinfo("OS ERROR", "Unfortunately you are using unsupported type of OS." + - "\n\nCurrently supported are:\n- %s" % ("\n- ".join(supported_os))) \ No newline at end of file diff --git a/pictures/preview.jpg b/pictures/preview.jpg index f949d1e..2247755 100644 Binary files a/pictures/preview.jpg and b/pictures/preview.jpg differ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eea2c18 --- /dev/null +++ b/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..97cc40b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pywin32==303 +pytest==7.1.1 +pyinstaller==4.10 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ad4cec7 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup, find_packages + + +setup(name='VTFChanger', + version='1.1', + description='Software that allows to change between versions of VTF images', + author='Mixanik', + url='https://github.com/Mix-Anik/Easy-VTF-Converter', + packages=find_packages(), + ) diff --git a/src/c_structs.py b/src/c_structs.py new file mode 100644 index 0000000..32e66b0 --- /dev/null +++ b/src/c_structs.py @@ -0,0 +1,50 @@ +from struct import pack, unpack + + +class CType: + python_type = None + c_type_format = None + + def __init__(self, byte_data): + self.value = unpack(f'<{self.c_type_format}', byte_data)[0] + + def raw(self): + return pack(f'<{self.c_type_format}', self.value) + + def set(self, new_value): + if isinstance(new_value, self.python_type): + self.value = new_value + else: + print(f'New value must be of type "{self.python_type}"') + + +class CUint32(CType): + python_type = int + c_type_format = 'I' + + def __init__(self, byte_data): + super().__init__(byte_data) + + +class CUShort(CType): + python_type = int + c_type_format = 'H' + + def __init__(self, byte_data): + super().__init__(byte_data) + + +class CFloat(CType): + python_type = float + c_type_format = 'f' + + def __init__(self, byte_data): + super().__init__(byte_data) + + +class CUChar(CType): + python_type = int + c_type_format = 'B' + + def __init__(self, byte_data): + super().__init__(byte_data) diff --git a/src/explorers.py b/src/explorers.py new file mode 100644 index 0000000..5e6366a --- /dev/null +++ b/src/explorers.py @@ -0,0 +1,56 @@ +import win32gui as w +import re + + +def _normalise_text(controlText): + return controlText.lower().replace('&', '') + + +def _window_enumeration_handler(hwnd, result_list): + result_list.append((hwnd, w.GetWindowText(hwnd), w.GetClassName(hwnd))) + + +def find_child_windows(current_hwnd, wanted_class=None): + results = [] + children = [] + + try: + w.EnumChildWindows(current_hwnd, _window_enumeration_handler, children) + except w.error: + return + + for child_hwnd, _, window_class in children: + if wanted_class and not window_class == wanted_class: + continue + + results.append(child_hwnd) + + return results + + +def window_iterator(hwnd, output): + if w.IsWindowVisible(hwnd) and w.GetClassName(hwnd) == "CabinetWClass": + output.append(hwnd) + + +def get_explorer_window_paths(): + windows = [] + paths = [] + w.EnumWindows(window_iterator, windows) + + for window in windows: + children = list(set(find_child_windows(window, wanted_class="ToolbarWindow32"))) + path = None + + for child in children: + parent = w.GetParent(child) + window_text = w.GetWindowText(child) + has_address = re.search(r".:\\", window_text) + + if has_address and w.GetClassName(parent) == "Breadcrumb Parent": + start_idx = has_address.span()[0] + path = window_text[start_idx:] + + if path: paths.append(path) + + return paths diff --git a/src/gui.py b/src/gui.py new file mode 100644 index 0000000..8d6a2dc --- /dev/null +++ b/src/gui.py @@ -0,0 +1,168 @@ +import tkinter as tk + +from tkinter.messagebox import showinfo +from tkinter.ttk import Progressbar +from platform import system +from time import time + +from src.helpers import find_textures +from src.explorers import get_explorer_window_paths +from src.vtf_structs import VTFFile + + +OS_TYPE = system() +VERSION = "1.1" +supported_os = ["Windows"] + + +def clear_console(): + console.configure(state=tk.NORMAL) + console.delete('1.0', tk.END) + console.configure(state=tk.DISABLED) + + +def write_to_console(text): + console.configure(state=tk.NORMAL) + console.insert(tk.INSERT, text+"\n") + console.see(tk.END) + console.configure(state=tk.DISABLED) + + +def convert_single_texture(texture_path, version): + tex_file = open(texture_path, mode="r+b") + bytelist = tex_file.read() + tex_file.close() + + minor_version = int(version[-1]) + + # Just in case, so it wouldn't stop processing other files + try: + # Creating VTF-type object & converting to requested version + vtf = VTFFile(bytelist) + vtf.convert(minor_version) + + # Writing new file (replacing old one) + tex_file = open(texture_path, 'wb') + tex_file.write(vtf.compose()) + tex_file.close() + except Exception: + write_to_console(f'Failed to convert "{texture_path}"') + + +def convert_textures(): + progress["value"] = 0 + clear_console() + sel_path_idxs = path_selector.curselection() + + if len(sel_path_idxs) > 0: + start_time = time() + textures_paths = find_textures([path_selector.get(sel_path_idx) for sel_path_idx in sel_path_idxs], bool(subfolders_var.get())) + progress["value"] = 0 + + write_to_console("Found %s texture(s) in %s directory(ies)" % (len(textures_paths), len(sel_path_idxs))) + write_to_console("Converting to VTF version %s" % version_var.get()) + + for i in range(len(textures_paths)): + tex_path = textures_paths[i] + convert_single_texture(tex_path, version_var.get()) + progress["value"] += 100 / len(textures_paths) + window.update() + + execution_time = round(time() - start_time, 2) + write_to_console("Done in %s seconds!" % execution_time) + elif path_selector.size() == 0: + write_to_console("You don't have any directories open!") + else: + write_to_console("You haven't selected any paths!") + + +def update_explorer_data(msg=True): + clear_console() + path_selector.delete(0, tk.END) + explorer_paths = get_explorer_window_paths() + + for path in explorer_paths: + path_selector.insert(tk.END, path) + + if msg: + write_to_console("Updated!") + + +if OS_TYPE in supported_os: + # Main software window and frame setup + window = tk.Tk() + window.geometry('500x290') + window.resizable(False, False) + window.title("Easy VTF Converter %s" % VERSION) + + mainframe = tk.Frame() + mainframe.pack(padx=8, pady=8) + mainframe.grid_rowconfigure(0, weight=1) + mainframe.grid_columnconfigure(0, weight=1) + + # Left part of the window (the one with selection box) + path_selector = tk.Listbox(mainframe, selectmode="multiple", width=60) + path_selector.grid(row=0, column=0, rowspan=5) + path_selector.config(activestyle="none", background="#fefefe", fg="#000000", relief=tk.SOLID) + + yscrollbar = tk.Scrollbar(mainframe, orient="vertical") + yscrollbar.config(command=path_selector.yview) + yscrollbar.grid(row=0, column=1, rowspan=5, sticky=tk.N + tk.S) + + xscrollbar = tk.Scrollbar(mainframe, orient="horizontal") + xscrollbar.config(command=path_selector.xview) + xscrollbar.grid(row=5, column=0, sticky=tk.E + tk.W) + + path_selector.config(yscrollcommand=yscrollbar.set, xscrollcommand=xscrollbar.set) + + # Right part of the window (buttons, selectors) + versions = ['7.0', '7.1', '7.2', '7.3', '7.4', '7.5'] + version_var = tk.StringVar(mainframe, '7.2') + subfolders_var = tk.IntVar(mainframe, 1) + + # Version selector + tk.Label(mainframe, text="Convert to").grid(row=0, column=2) + vselector = tk.OptionMenu(mainframe, version_var, *versions) + vselector.config(width=2, relief=tk.SOLID, bd=1, fg="#000acc") + vselector.grid(row=0, column=3, sticky=tk.E + tk.W) + + subfolders_cb = tk.Checkbutton(mainframe, text="subfolders too?", variable=subfolders_var) + subfolders_cb.grid(row=1, column=2, columnspan=2, sticky=tk.E + tk.W) + + # Button's *onhover* handlers + def ud_hover_in(e): update_btn['background'] = '#edfaff' + def ud_hover_out(e): update_btn['background'] = 'SystemButtonFace' + def cv_hover_in(e): convert_btn['background'] = '#edfaff' + def cv_hover_out(e): convert_btn['background'] = 'SystemButtonFace' + + # Buttons + update_btn = tk.Button(mainframe, padx=6, text="Update", command=update_explorer_data) + update_btn.config(relief=tk.SOLID, bd=1, fg="#000acc") + update_btn.grid(row=2, column=2, columnspan=2, pady=8, sticky=tk.E + tk.W) + update_btn.bind("", ud_hover_in) + update_btn.bind("", ud_hover_out) + + convert_btn = tk.Button(mainframe, padx=6, text="Convert", command=convert_textures) + convert_btn.config(relief=tk.SOLID, bd=1, fg="#000acc", font=("TkDefaultFont", 10, "bold")) + convert_btn.grid(row=3, column=2, columnspan=2, sticky=tk.E + tk.W) + convert_btn.bind("", cv_hover_in) + convert_btn.bind("", cv_hover_out) + + # Bottom part of the window (console and progress bar) + console = tk.Text(mainframe, relief=tk.SUNKEN, background="gray85", foreground="#000000") + console.config(height=6) + console.grid(row=6, column=0, columnspan=4, pady=6, sticky=tk.E + tk.W) + + progress = Progressbar(mainframe, orient="horizontal", mode="determinate") + progress.grid(row=7, column=0, columnspan=4, sticky=tk.E + tk.W) + progress["maximum"] = 100 + progress["value"] = 0 + + # adding choices to the selection box + update_explorer_data(False) + window.mainloop() +else: + window = tk.Tk() + window.withdraw() + showinfo("OS ERROR", "Unfortunately you are using unsupported type of OS." + + "\n\nCurrently supported are:\n- %s" % ("\n- ".join(supported_os))) diff --git a/src/helpers.py b/src/helpers.py new file mode 100644 index 0000000..9cffcce --- /dev/null +++ b/src/helpers.py @@ -0,0 +1,19 @@ +from os import walk +from os.path import join + + +def find_textures(dir_paths, subfolders=True): + texture_paths = [] + + for path in dir_paths: + for subdir, _, filenames in walk(path): + for filename in filenames: + abs_path = join(subdir, filename) + + if filename[-4:] == ".vtf": + texture_paths.append(abs_path) + + if not subfolders: + break + + return texture_paths diff --git a/src/vtf_structs.py b/src/vtf_structs.py new file mode 100644 index 0000000..a13c7f8 --- /dev/null +++ b/src/vtf_structs.py @@ -0,0 +1,236 @@ +from struct import pack + +from src.c_structs import CUint32, CUShort, CFloat, CUChar + + +# For full reference see https://developer.valvesoftware.com/wiki/Valve_Texture_Format +NA = 0 +# Image data format table ([R,G,B,A], Total bits) +FORMATS = { + 4294967295: None, + 0: ([8, 8, 8, 8], 32), + 1: ([8, 8, 8, 8], 32), + 2: ([8, 8, 8, 0], 24), + 3: ([8, 8, 8, 0], 24), + 4: ([5, 6, 5, 0], 16), + 5: ([NA, NA, NA, NA], 8), + 6: ([NA, NA, NA, 8], 16), + 7: ([NA, NA, NA, NA], 8), + 8: ([0, 0, 0, 8], 8), + 9: ([8, 8, 8, 0], 24), + 10: ([8, 8, 8, 0], 24), + 11: ([8, 8, 8, 8], 32), + 12: ([8, 8, 8, 8], 32), + 13: ([NA, NA, NA, 0], 4), + 14: ([NA, NA, NA, 4], 8), + 15: ([NA, NA, NA, 8], 8), + 16: ([8, 8, 8, 8], 32), + 17: ([5, 6, 5, 0], 16), + 18: ([5, 5, 5, 1], 16), + 19: ([4, 4, 4, 4], 16), + 20: ([NA, NA, NA, 1], 4), + 21: ([5, 5, 5, 1], 16), + 22: ([NA, NA, NA, NA], 16), + 23: ([NA, NA, NA, NA], 32), + 24: ([16, 16, 16, 16], 64), + 25: ([16, 16, 16, 16], 64), + 26: ([NA, NA, NA, NA], 32) +} + + +class VTFHeader: + def __init__(self, bin_data): + self.type_string = bin_data[:4] # 4 bytes | char x4 | "Magic number" identifier + self.version_major = CUint32(bin_data[4:8]) # 4 bytes | uint32 | Major vtf version number + self.version_minor = CUint32(bin_data[8:12]) # 4 bytes | uint32 | Minor vtf version number + self.header_size = CUint32(bin_data[12:16]) # 4 bytes | uint32 | Size of the header struct (16 bytes aligned) + self.l_width = CUShort(bin_data[16:18]) # 2 bytes | ushort16 | Width of the largest image + self.l_height = CUShort(bin_data[18:20]) # 2 bytes | ushort16 | Height of the largest image + self.flags = CUint32(bin_data[20:24]) # 4 bytes | uint32 | Flags for the image + self.frames = CUShort(bin_data[24:26]) # 2 bytes | ushort16 | Number of frames if animated (1 if no animation) + self.start_frame = CUShort(bin_data[26:28]) # 2 bytes | ushort16 | Start frame (always 0) + self.padding0 = b'\x00'*4 # 4 bytes | uchar x4 | Reflectivity padding (16 byte alignment) + self.reflectivity = [CFloat(bin_data[32:36]), + CFloat(bin_data[36:40]), + CFloat(bin_data[40:44])] # 12 bytes | float x3 | Reflectivity vector + self.padding1 = b'\x00'*4 # 4 bytes | uchar x4 | Reflectivity padding (8 byte packing) + self.bumpmap_scale = CFloat(bin_data[48:52]) # 4 bytes | float x1 | Bump map scale + self.image_format = CUint32(bin_data[52:56]) # 4 bytes | uint32 | Image format index + self.mip_count = CUChar(bin_data[56:57]) # 1 bytes | uchar | Number of MIP levels (including the largest image) + self.low_res_image_format = CUint32(bin_data[57:61]) # 4 bytes | uint32 | Image format of the thumbnail image + self.low_res_image_width = CUChar(bin_data[61:62]) # 1 bytes | uchar | Thumbnail image width + self.low_res_image_height = CUChar(bin_data[62:63]) # 1 bytes | uchar | Thumbnail image height + + if self.version_minor.value > 1: + self.depth = CUShort(bin_data[63:65]) # 2 bytes | ushort16 | Depth of the largest mipmap in pixels + + if self.version_minor.value > 2: + self.padding2 = b'\x00'*3 # 3 bytes | uchar x3 | Num resource padding + self.num_resource = CUint32(bin_data[68:72]) # 4 bytes | uint32 | Number of resources this vtf has + self.padding3 = b'\x00'*8 # 8 bytes | uchar x8 | Num resource padding + + def compose(self): + composed_header = self.type_string + self.version_major.raw() + self.version_minor.raw() + \ + self.header_size.raw() + self.l_width.raw() + self.l_height.raw() + \ + self.flags.raw() + self.frames.raw() + self.start_frame.raw() + \ + self.padding0 + b''.join([x.raw() for x in self.reflectivity]) + \ + self.padding1 + self.bumpmap_scale.raw() + self.image_format.raw() + \ + self.mip_count.raw() + self.low_res_image_format.raw() + \ + self.low_res_image_width.raw() + self.low_res_image_height.raw() + + if self.version_minor.value > 1: + composed_header += self.depth.raw() + + if self.version_minor.value > 2: + composed_header += self.padding2 + self.num_resource.raw() + self.padding3 + + # Filling unused bytes + if self.version_minor.value < 3: + composed_header += b'\x00' * (self.header_size.value - len(composed_header)) + + return composed_header + + +class VTFResource: + def __init__(self, res_bin_data): + self.tag = res_bin_data[:3] # 3 bytes | uchar x3 | A three-byte "tag" that identifies what this resource is + self.flags = CUChar(res_bin_data[3:4]) # 1 byte | uchar | Resource entry flags + self.offset = CUint32(res_bin_data[4:8]) # 4 bytes | uint32 | The offset of this resource's data in the file + + def compose(self): + return self.tag + self.flags.raw() + self.offset.raw() + + +class VTFFile: + def __init__(self, bin_data): + self.header = VTFHeader(bin_data) + self.resources = [] + + if self.header.version_minor.value > 2: + for i in range(self.header.num_resource.value): + resource_data = bin_data[80 + 8 * i: 80 + 8 * (i + 1)] + resource = VTFResource(resource_data) + + if resource.tag in [b'\x01\x00\x00', b'\x30\x00\x00', b'\x10\x00\x00', b'CRC', b'LOD', b'TSO', b'KVD']: + self.resources.append(resource) + + # Image resource data + # Doesn't really matter whether we divide low- and high-res image data for 7.3+ format or not + # Unless we change the size of image data or offsets in header resource data + self.image_data = bin_data[self.header.header_size.value:] + # High resolution image data fromat [mipmap[frame[face[slice[RGBA]]]]] + + def convert(self, version): + """ + Conversion: + 1. Change vtf minor version + 2. Change vtf header size according to new minor version + 3.1 Add 'Depth' value to the header (7.2+) + 3.1.1 Find it if converting from <7.2 + 3.2 Add 'Resource number' value to the header (7.3+) + 3.2.1 Find it if converting from <7.3 + 3.3 Add 'Resources' data to the header (7.3+) + 3.3.1 Find it if converting from <7.3 + 4 Add Image/Resource data after header + """ + + # TODO 3.1.1: Find actual depth of largest mipmap + # But 99% of chance it's '1' + + # TODO 3.2.1: Find actual number of resources? (mby not neccessary) + # Usually we can assume it's 2, ignoring all resources except low/high-res data + + # TODO 3.3.1: Find actual resource flags, instead of nullifying them + + if not 0 <= version <= 5: + print("Only versions 7.0-7.5 are supported") + return + + # Changing minor version + self.header.version_minor.set(version) + + # Changing header size + if version < 2: + self.header.header_size.set(64) + elif version == 2: + self.header.header_size.set(80) + + # Adding 'depth' data + if version >= 2: + if 'depth' not in vars(self.header): + self.header.depth = CUShort(b'\x01\x00') + + # Adding resources data + if version > 2: + if 'num_resource' not in vars(self.header): + # Finding high-res data offset + low_res_format_desc = FORMATS[self.header.low_res_image_format.value] + + self.header.padding2 = b'\x00'*3 + self.header.num_resource = CUint32(b'\x02\x00\x00\x00') + self.header.padding3 = b'\x00'*8 + self.header.header_size.set(80 + self.header.num_resource.value * 8) + self.resources = [] + + # Check if thumbnail exists + if low_res_format_desc: + low_res_resource = VTFResource(b'\x01\x00\x00' + b'\x00' + self.header.header_size.raw()) + self.resources.append(low_res_resource) + + smallest_dimension = min(self.header.low_res_image_width.value, self.header.low_res_image_height.value) + high_res_offset_n = smallest_dimension * low_res_format_desc[1] * 2 + self.header.header_size.value + high_res_offset_b = pack(" 1: + print("Depth: %s" % self.header.depth.value) + + if self.header.version_minor.value > 2: + print("Resource num start padding: %s" % self.header.padding2) + print("Resource amount: %s" % self.header.num_resource.value) + print("Resource num end padding: %s" % self.header.padding3) + print("Resources contained:") + + for i, res in enumerate(self.resources): + print("%s." % (i+1)) + print("\tTag: %s" % res.tag) + print("\tFlags integer: %s" % res.flags.value) + print("\tOffset: %s" % res.offset.value) + + print("Image data: %s bytes" % len(self.image_data)) + + def compose(self): + composed_vtf = self.header.compose() + + if self.header.version_minor.value > 2: + for res in self.resources: + composed_vtf += res.compose() + + composed_vtf += self.image_data + + return composed_vtf diff --git a/tests/samples/sample1.vtf b/tests/samples/sample1.vtf new file mode 100644 index 0000000..1b890f1 Binary files /dev/null and b/tests/samples/sample1.vtf differ diff --git a/tests/samples/sample10.vtf b/tests/samples/sample10.vtf new file mode 100644 index 0000000..196f40a Binary files /dev/null and b/tests/samples/sample10.vtf differ diff --git a/tests/samples/sample2.vtf b/tests/samples/sample2.vtf new file mode 100644 index 0000000..af1bbbe Binary files /dev/null and b/tests/samples/sample2.vtf differ diff --git a/tests/samples/sample3.vtf b/tests/samples/sample3.vtf new file mode 100644 index 0000000..c99868f Binary files /dev/null and b/tests/samples/sample3.vtf differ diff --git a/tests/samples/sample4.vtf b/tests/samples/sample4.vtf new file mode 100644 index 0000000..d90ab8b Binary files /dev/null and b/tests/samples/sample4.vtf differ diff --git a/tests/samples/sample5.vtf b/tests/samples/sample5.vtf new file mode 100644 index 0000000..d877167 Binary files /dev/null and b/tests/samples/sample5.vtf differ diff --git a/tests/samples/sample6.vtf b/tests/samples/sample6.vtf new file mode 100644 index 0000000..5634e49 Binary files /dev/null and b/tests/samples/sample6.vtf differ diff --git a/tests/samples/sample7.vtf b/tests/samples/sample7.vtf new file mode 100644 index 0000000..68a2b7c Binary files /dev/null and b/tests/samples/sample7.vtf differ diff --git a/tests/samples/sample8.vtf b/tests/samples/sample8.vtf new file mode 100644 index 0000000..295c001 Binary files /dev/null and b/tests/samples/sample8.vtf differ diff --git a/tests/samples/sample9.vtf b/tests/samples/sample9.vtf new file mode 100644 index 0000000..e8aa422 Binary files /dev/null and b/tests/samples/sample9.vtf differ diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a51267e --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,60 @@ +import itertools +import pytest +import os +import re +import subprocess +import shutil + +from pathlib import Path + +from src.vtf_structs import VTFFile + + +SAMPLES_FOLDER_PATH = f'{Path(__file__).parent}\\samples' +SAMPLES_PATHS = [f'{absp}\\{fname}' for (absp, _, fnames) in os.walk(SAMPLES_FOLDER_PATH) for fname in fnames] +TEST_PARAM_LIST = [tuple([f, *v]) for v in itertools.product(range(6), repeat=2) for f in SAMPLES_PATHS] + + +class TestVTFChanger: + + def setup_class(self): + self.VTFCMD_path = '' + self.temp_fname = 'temp' + + @pytest.mark.parametrize('file_path,from_version,to_version', TEST_PARAM_LIST) + def test_conversion(self, file_path: str, from_version: int, to_version: int): + temp_file_path = f'{SAMPLES_FOLDER_PATH}\\{self.temp_fname}.vtf' + shutil.copyfile(file_path, temp_file_path) + self._convert_texture_to_version(temp_file_path, from_version) + self._convert_texture_to_version(temp_file_path, to_version) + self._validate_vtf(temp_file_path) + + def _convert_texture_to_version(self, texture_path: str, to_version: int): + with open(texture_path, mode="r+b") as tex_file: + bytelist = tex_file.read() + + vtf = VTFFile(bytelist) + vtf.convert(to_version) + + with open(texture_path, 'wb') as tex_file: + tex_file.write(vtf.compose()) + + def _validate_vtf(self, vtf_abs_path: str): + output = subprocess.check_output([self.VTFCMD_path, '-exportformat', 'tga', '-file', vtf_abs_path]) + is_successful = re.findall(rb'1/1 files completed.', output) + + assert is_successful, 'VTF ended up being corrupted during conversion' + + def teardown(self): + # cleanup + temp_file_path = f'{SAMPLES_FOLDER_PATH}\\{self.temp_fname}' + + try: + os.remove(f'{temp_file_path}.vtf') + except OSError: + pass + + try: + os.remove(f'{temp_file_path}.tga') + except OSError: + pass diff --git a/vtf_converter.py b/vtf_converter.py deleted file mode 100644 index f132c9e..0000000 --- a/vtf_converter.py +++ /dev/null @@ -1,35 +0,0 @@ -from os import listdir, walk -from os.path import isfile, join -from vtf_structs import VTF_File - - -def find_textures(dir_paths): - texture_paths = [] - - for path in dir_paths: - for subdir, dirs, files in walk(path): - texture_paths += [join(subdir, fname) for fname in listdir(subdir) if - (isfile(join(subdir, fname)) and fname[-4:] == ".vtf")] - - return texture_paths - -def convert(texture_path, version): - tex_file = open(texture_path, mode="r+b") - bytelist = tex_file.read() - tex_file.close() - - minor_version = int(version[-1]) - - # Creating VTF-type object & converting to requested version - vtf = VTF_File(bytelist) - - # Just in case, so it wouldn't stop processing other files - try: - vtf.convert(minor_version) - except: - print("Unexpected error") - - # Writing new file (replacing old one) - tex_file = open(texture_path, 'wb') - tex_file.write(vtf.compose()) - tex_file.close() \ No newline at end of file diff --git a/vtf_structs.py b/vtf_structs.py deleted file mode 100644 index 470071c..0000000 --- a/vtf_structs.py +++ /dev/null @@ -1,238 +0,0 @@ -from c_structs import * - - -# For full reference watch https://developer.valvesoftware.com/wiki/Valve_Texture_Format - - -NA = 0 -# Formats are in format ([R,G,B,A], T) -FORMATS = { - 4294967295: None, - 0: ([8,8,8,8], 32), - 1: ([8,8,8,8], 32), - 2: ([8,8,8,0], 24), - 3: ([8,8,8,0], 24), - 4: ([5,6,5,0], 16), - 5: ([NA,NA,NA,NA], 8), - 6: ([NA,NA,NA,8], 16), - 7: ([NA,NA,NA,NA], 8), - 8: ([0,0,0,8], 8), - 9: ([8,8,8,0], 24), - 10: ([8,8,8,0], 24), - 11: ([8,8,8,8], 32), - 12: ([8,8,8,8], 32), - 13: ([NA,NA,NA,0], 4), - 14: ([NA,NA,NA,4], 8), - 15: ([NA,NA,NA,8], 8), - 16: ([8,8,8,8], 32), - 17: ([5,6,5,0], 16), - 18: ([5,5,5,1], 16), - 19: ([4,4,4,4], 16), - 20: ([NA,NA,NA,1], 4), - 21: ([5,5,5,1], 16), - 22: ([NA,NA,NA,NA], 16), - 23: ([NA,NA,NA,NA], 32), - 24: ([16,16,16,16], 64), - 25: ([16,16,16,16], 64), - 26: ([NA,NA,NA,NA], 32) -} - - -class VTF_Header(): - def __init__(self, bin_data): - self.type_string = bin_data[:4] # 4 bytes | char x4 | "Magic number" identifier - self.version_major = c_uint32(bin_data[4:8]) # 4 bytes | uint32 | Major vtf version number - self.version_minor = c_uint32(bin_data[8:12]) # 4 bytes | uint32 | Minor vtf version number - self.header_size = c_uint32(bin_data[12:16]) # 4 bytes | uint32 | Size of the header struct (16 bytes aligned) - self.l_width = c_ushort(bin_data[16:18]) # 2 bytes | ushort16 | Width of the largest image - self.l_height = c_ushort(bin_data[18:20]) # 2 bytes | ushort16 | Height of the largest image - self.flags = c_uint32(bin_data[20:24]) # 4 bytes | uint32 | Flags for the image - self.frames = c_ushort(bin_data[24:26]) # 2 bytes | ushort16 | Number of frames if animated (1 if no animation) - self.start_frame = c_ushort(bin_data[26:28]) # 2 bytes | ushort16 | Start frame (always 0) - self.padding0 = b'\x00'*4 # 4 bytes | uchar x4 | Reflectivity padding (16 byte alignment) - self.reflectivity = [c_float(bin_data[32:36]), - c_float(bin_data[36:40]), - c_float(bin_data[40:44])] # 12 bytes | float x3 | Reflectivity vector - self.padding1 = b'\x00'*4 # 4 bytes | uchar x4 | Reflectivity padding (8 byte packing) - self.bumpmap_scale = c_float(bin_data[48:52]) # 4 bytes | float x1 | Bump map scale - self.image_format = c_uint32(bin_data[52:56]) # 4 bytes | uint32 | Image format index - self.mip_count = c_uchar(bin_data[56:57]) # 1 bytes | uchar | Number of MIP levels (including the largest image) - self.low_res_image_format = c_uint32(bin_data[57:61]) # 4 bytes | uint32 | Image format of the thumbnail image - self.low_res_image_width = c_uchar(bin_data[61:62]) # 1 bytes | uchar | Thumbnail image width - self.low_res_image_height = c_uchar(bin_data[62:63]) # 1 bytes | uchar | Thumbnail image height - - if self.version_minor.value > 1: - self.depth = c_ushort(bin_data[63:65]) # 2 bytes | ushort16 | Depth of the largest mipmap in pixels - - if self.version_minor.value > 2: - self.padding2 = b'\x00'*3 # 3 bytes | uchar x3 | Num resource padding - self.num_resource = c_uint32(bin_data[68:72]) # 4 bytes | uint32 | Number of resources this vtf has - self.padding3 = b'\x00'*8 # 8 bytes | uchar x8 | Num resource padding - self.resources = [] - - for i in range(self.num_resource.value): - resource_data = bin_data[80 + 8*i : 80 + 8*(i+1)] - resource = VTF_Resource(resource_data) - - # We can basically ignore other resource types, leaving low-res and high-res ones - if resource.tag in [b'\x01\x00\x00', b'\x30\x00\x00']: - self.resources.append(resource) - - def compose(self): - composed_header = self.type_string + self.version_major.raw() + self.version_minor.raw() + \ - self.header_size.raw() + self.l_width.raw() + self.l_height.raw() + \ - self.flags.raw() + self.frames.raw() + self.start_frame.raw() + \ - self.padding0 + b''.join([x.raw() for x in self.reflectivity]) + \ - self.padding1 + self.bumpmap_scale.raw() + self.image_format.raw() + \ - self.mip_count.raw() + self.low_res_image_format.raw() + \ - self.low_res_image_width.raw() + self.low_res_image_height.raw() - - if self.version_minor.value > 1: - composed_header += self.depth.raw() - - if self.version_minor.value > 2: - composed_header += self.padding2 + self.num_resource.raw() + self.padding3 - - for res in self.resources: - composed_header += res.compose() - - # Filling unused bytes - composed_header += b'\x00' * (self.header_size.value - len(composed_header)) - - return composed_header - - - -class VTF_Resource(): - def __init__(self, res_bin_data): - self.tag = res_bin_data[:3] # 3 bytes | uchar x3 | A three-byte "tag" that identifies what this resource is - self.flags = c_uchar(res_bin_data[3:4]) # 1 byte | uchar | Resource entry flags - self.offset = c_uint32(res_bin_data[4:8]) # 4 bytes | uint32 | The offset of this resource's data in the file - - def description(self): - print("Tag: %s" % self.tag) - print("Flags integer: %s" % self.flags.value) - print("Offset: %s" % self.offset.value) - - def compose(self): - return self.tag + self.flags.raw() + self.offset.raw() - - -class VTF_File(): - def __init__(self, bin_data): - self.header = VTF_Header(bin_data) # VTF Header - - # Image resource data - # Doesn't really matter whether we divide low- and high-res image data for 7.3+ format or not - # Unless we change the size of image data or offsets in header resource data - self.image_data = bin_data[self.header.header_size.value:] - - # High resolution image data fromat [mipmap[frame[face[slice[RGBA]]]]] - - def convert(self, version): - ''' - Conversion: - 1. Change vtf minor version - 2. Change vtf header size according to new minor version - 3.1 Add 'Depth' value to the header (7.2+) - 3.1.1 Find it if converting from <7.2 - 3.2 Add 'Resource number' value to the header (7.3+) - 3.2.1 Find it if converting from <7.3 - 3.3 Add 'Resources' data to the header (7.3+) - 3.3.1 Find it if converting from <7.3 - 4 Add Image/Resource data after header - ''' - - # TODO 3.1.1: Find actual depth of largest mipmap - # But 99% of chance it's '1' - - # TODO 3.2.1: Find actual number of resources? (mby not neccessary) - # Usually we can assume it's 2, ignoring all resources except low/high-res data - - # TODO 3.3.1: Find actual resource flags, instead of nullifying them - - if not 0 <= version <= 5: - print("Only versions 7.0-7.5 are supported") - return - - # Changing minor version - self.header.version_minor.set(version) - - # Changing header size - if version < 2: - self.header.header_size.set(64) - elif version == 2: - self.header.header_size.set(80) - elif version > 2: - self.header.header_size.set(96) - - # Adding 'depth' data - if version >= 2: - if 'depth' not in self.header.__dict__: - self.header.depth = c_ushort(b'\x01\x00') - - # Adding resources data - if version > 2: - if 'num_resource' not in self.header.__dict__: - # Finding high-res data offset - low_res_format_desc = FORMATS[self.header.low_res_image_format.value] - - self.header.padding2 = b'\x00'*3 - self.header.num_resource = c_uint32(b'\x02\x00\x00\x00') - self.header.padding3 = b'\x00'*8 - self.header.resources = [] - - # Check if thumbnail exists - if low_res_format_desc: - # Low-res resource data (tag + flags + offset) - low_res_resource = VTF_Resource(b'\x01\x00\x00' + b'\x00' + self.header.header_size.raw()) - self.header.resources.append(low_res_resource) - - high_res_offset_n = self.header.low_res_image_width.value * low_res_format_desc[1] + \ - self.header.low_res_image_height.value * low_res_format_desc[1] + \ - self.header.header_size.value - high_res_offset_b = pack(" 1: - print("Depth: %s" % self.header.depth.value) - if self.header.version_minor.value > 2: - print("Resource num start padding: %s" % self.header.padding2) - print("Resource amount: %s" % self.header.num_resource.value) - print("Resource num end padding: %s" % self.header.padding3) - - print("Resources contained:") - for i, res in enumerate(self.header.resources): - print("%s." % (i+1)) - res.description() - - print("Image data: %s bytes" % len(self.image_data)) - - def compose(self): - return self.header.compose() + self.image_data \ No newline at end of file