├── LICENSE.txt ├── README.md ├── README.txt ├── SkullMod2.py ├── SkullModPy2 ├── __init__.py ├── app_info.py ├── gfs.py ├── main.py ├── resources.py ├── ui.py └── util.py ├── app.ico └── requirements.txt /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SkullMod2 2 | 3 | See releases for download 4 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | # SkullMod 2, Unofficial modding tool for Skullgirls 2 | 3 | # How to build 4 | Get Python 3.9+ 5 | Make sure the Python exe is available in PATH (should be by default after installing Python) or create a venv 6 | Only tested on Windows 10, if you want to build it on Linux or Mac OS remove the winreg import and everything associated 7 | 8 | # Install dependency 9 | pip install wxpython 10 | 11 | # Run it directly 12 | Execute SkullMod2.py with Python 13 | 14 | # ... or make an exe out the package 15 | pip install pyinstaller 16 | Run in the same directory as SkullMod2.py: 17 | pyinstaller --windowed --onefile --icon app.ico SkullMod2.py 18 | Resulting exe file will be in the dist directory -------------------------------------------------------------------------------- /SkullMod2.py: -------------------------------------------------------------------------------- 1 | from SkullModPy2.main import main 2 | 3 | if __name__ == '__main__': 4 | main() -------------------------------------------------------------------------------- /SkullModPy2/__init__.py: -------------------------------------------------------------------------------- 1 | # -------------------------------------------------------------------------------- /SkullModPy2/app_info.py: -------------------------------------------------------------------------------- 1 | APPLICATION_NAME = "SkullModPy2" 2 | APPLICATION_VERSION = "1.0" 3 | APPLICATION_DATE = "2021-04-13" 4 | APPLICATION_LICENSE = """CC0 1.0 Universal 5 | See LICENSE.txt or https://creativecommons.org/publicdomain/zero/1.0/legalcode""" -------------------------------------------------------------------------------- /SkullModPy2/gfs.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import os 3 | import struct 4 | 5 | from SkullModPy2.util import read_int, read_pascal_string, write_pascal_string 6 | 7 | 8 | FILE_IDENTIFIER = 'Reverge Package File' 9 | FILE_EXTENSION = 'gfs' 10 | FILE_VERSION = '1.1' 11 | 12 | 13 | @dataclass 14 | class GfsMetadataEntry: 15 | local_path: str 16 | offset: int 17 | size: int 18 | 19 | 20 | @dataclass 21 | class GfsFilesystemEntry: 22 | absolute_path: str 23 | size: int 24 | 25 | 26 | def read_gfs_header(filename) -> list[GfsMetadataEntry]: 27 | with open(filename, 'rb') as file: 28 | data_offset = read_int(file, 4, False) 29 | if data_offset < 48: 30 | raise ValueError('GFS file header is too short') 31 | file_identifier_length = read_int(file, 8, False) 32 | if file_identifier_length != len(FILE_IDENTIFIER): 33 | raise ValueError('Not a GFS file') 34 | file_identifier = str(file.read(len(FILE_IDENTIFIER)), 'ascii') 35 | if file_identifier != FILE_IDENTIFIER: 36 | raise ValueError('Not a GFS file') 37 | file_version = read_pascal_string(file) 38 | if not file_version == FILE_VERSION: 39 | raise ValueError('Wrong GFS version') 40 | n_of_files = read_int(file, 8, False) 41 | 42 | # Process 43 | running_offset = data_offset 44 | references = [] 45 | for _ in range(n_of_files): 46 | reference_path = read_pascal_string(file) 47 | reference_length = read_int(file, 8, False) 48 | reference_alignment = read_int(file, 4, False) 49 | # The alignment is already included 50 | running_offset += (reference_alignment - (running_offset % reference_alignment)) % reference_alignment 51 | references.append(GfsMetadataEntry(reference_path, running_offset, reference_length)) 52 | 53 | running_offset += reference_length 54 | return references 55 | 56 | 57 | def get_metadata(file_path: str) -> list[GfsMetadataEntry]: 58 | if file_path is None or not os.path.isfile(file_path) or not os.path.splitext(file_path)[1] == '.gfs': 59 | return None 60 | if os.path.getsize(file_path) < 48: 61 | return None 62 | try: 63 | return read_gfs_header(file_path) 64 | except Exception: 65 | return None 66 | 67 | 68 | def get_files_in_dir(path: str) -> list[GfsFilesystemEntry]: 69 | # Generate file list for the directory 70 | result = [] 71 | for root, subdirs, files in os.walk(path): 72 | # Go through all files in this directory 73 | # Save their relative positions and size 74 | for file in files: 75 | file_path = os.path.join(root, file) 76 | result.append(GfsFilesystemEntry(file_path, os.path.getsize(file_path))) 77 | return result 78 | 79 | 80 | def write_content(base_path, entries: list[GfsFilesystemEntry], aligned: bool): 81 | base_path_len = len(base_path) 82 | alignment = 4096 if aligned else 1 83 | header_length = 51 # Base size (contains offset/file string/version/nOfFiles) 84 | for entry in entries: 85 | entry_path_len = len(entry.absolute_path[base_path_len+1:].replace('\\', '/')) 86 | header_length += 8 + entry_path_len + 8 + 4 # long strLength+fileName+long fileSize+uint alignment 87 | # Write header 88 | with open(base_path + '.gfs', 'wb') as f: 89 | f.write(struct.pack('>L', header_length)) 90 | write_pascal_string(f, FILE_IDENTIFIER) 91 | write_pascal_string(f, FILE_VERSION) 92 | f.write(struct.pack('>Q', len(entries))) 93 | for entry in entries: 94 | write_pascal_string(f, entry.absolute_path[base_path_len+1:].replace('\\', '/')) 95 | f.write(struct.pack('>Q', entry.size)) 96 | f.write(struct.pack('>L', alignment)) 97 | if f.tell() % alignment != 0: # Only align if alignment is needed 98 | f.write(b'\x00' * (alignment - (f.tell() % alignment))) # Align header if needed 99 | for entry in entries: 100 | # Open file, read chunks, write chunks into this file 101 | with open(entry.absolute_path, 'rb') as data_file: 102 | bytes_read = data_file.read(4096) 103 | while bytes_read: 104 | f.write(bytes_read) 105 | bytes_read = data_file.read(4096) 106 | if f.tell() % alignment != 0: # Only align if alignment is needed 107 | f.write(b'\x00' * (alignment - (f.tell() % alignment))) # Write alignment 108 | 109 | 110 | def export_files(file_path: str, entries: list[GfsMetadataEntry]) -> bool: 111 | base_path = os.path.splitext(file_path)[0] 112 | base_path_length = len(base_path) 113 | with open(file_path, 'rb') as source_file: 114 | for entry in entries: 115 | source_file.seek(entry.offset) 116 | absolute_path_entry = os.path.abspath(os.path.join(base_path, entry.local_path.replace('/', '\\'))) 117 | 118 | if len(os.path.commonprefix([base_path, absolute_path_entry])) != base_path_length: 119 | # Directory traversal attack 120 | return False 121 | os.makedirs(os.path.dirname(absolute_path_entry), exist_ok=True) 122 | 123 | if entry.size < 0: 124 | return False 125 | with open(absolute_path_entry, 'wb', 4096) as output_file: 126 | for _ in range(int(entry.size / 4096)): 127 | output_file.write(source_file.read(4096)) 128 | output_file.write(source_file.read(entry.size % 4096)) 129 | return True 130 | -------------------------------------------------------------------------------- /SkullModPy2/main.py: -------------------------------------------------------------------------------- 1 | import wx 2 | 3 | from SkullModPy2.ui import MainForm 4 | 5 | 6 | def main(): 7 | app = wx.App(False) 8 | main_form = MainForm(None) 9 | main_form.Show() 10 | app.MainLoop() 11 | 12 | 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /SkullModPy2/resources.py: -------------------------------------------------------------------------------- 1 | from wx.lib.embeddedimage import PyEmbeddedImage 2 | 3 | # Icon 4 | SKM2 = PyEmbeddedImage( 5 | b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAB4klEQVRYw+1WvU7CUBT+DgWa' 6 | b'DkBIAEPSBRIHHkB3Y+LiwDMY42P4Am5uxsFEFxcXBuOggwtRX4KY6IDBEoEQKqVcF3NTS0t7' 7 | b'bklYPEkTzinf+b7e89MCazaKuC8Y/1XCUgSQK5yNJYUEK8VSWIJWvb6AanY6YYmUsQsJgsAx' 8 | b'kqliibjkYSJUsSnV8fEScsiD6iC4SSbzOZ4GA7RHI7y5LhqZDLZzOWzlcsimUqxTSHMVu0Lg' 9 | b'stvFnW3L2Md0ikfLwv54jMNqFRrFXxnsErx/f0vypmHgplbDTjYLALi1bXQ8wuIKoGWd7bev' 10 | b'2Uz+NnUdGSLkNU3GetMppwmJfQIzITwzRBi7Lh4mExkzPGK420yodHR7MMCJZUn/3DSx8VuS' 11 | b'qKcP7IG4pQCAoeviot+X/p6uoxKPPLAJiSvivt/Hp6ckzVIp9C3l34BhUxBbxKtt42o0kv5R' 12 | b'oQBT11nkYWMYKcIRAte9nvQbmobdYpFNvmwPLBXxMhzi2XGkf1ipwAjYgFHkUYsoUITlODj1' 13 | b'dP1BPo9Nw1Ai53xaCQCiVa+L43JZeGP+68w0/bGVmEwYJYBLTkwRS1+/Kseu/KXrF6Fac1Is' 14 | b'xx8RSRqOEvTESvJRwsZc2aglnQ5lS2HNlk6I1/BvCe0HI/LVM3ImXUQAAAAASUVORK5CYII=') 15 | 16 | -------------------------------------------------------------------------------- /SkullModPy2/ui.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import wx 4 | import wx.adv 5 | 6 | from SkullModPy2.app_info import * 7 | from SkullModPy2.resources import * 8 | from SkullModPy2.util import get_data_directory, human_readable_file_size 9 | from SkullModPy2.gfs import GfsMetadataEntry, get_metadata, get_files_in_dir, write_content, export_files 10 | 11 | 12 | class InternalListCtrlPanel(wx.Panel): 13 | def __init__(self, parent): 14 | wx.Panel.__init__(self, parent, wx.ID_ANY) 15 | resizer = wx.BoxSizer(wx.HORIZONTAL) 16 | self.internal_grid_string_table = InternalListCtrl(parent=self, style=wx.LC_REPORT) 17 | resizer.Add(self.internal_grid_string_table, 1, wx.EXPAND | wx.ALL) 18 | self.SetSizerAndFit(resizer) 19 | 20 | 21 | class InternalListCtrl(wx.ListCtrl): 22 | def __init__(self, *args, **kwargs): 23 | wx.ListCtrl.__init__(self, *args, **kwargs) 24 | self.InsertColumn(0, 'Path', width=400) 25 | self.InsertColumn(1, 'Size', width=100) 26 | 27 | def set_data(self, data: list[GfsMetadataEntry]): 28 | self.DeleteAllItems() 29 | if data is None: 30 | return 31 | self.Freeze() 32 | for y in range(len(data)): 33 | self.InsertItem(y, data[y].local_path) 34 | self.SetItem(y, 1, human_readable_file_size(data[y].size)) 35 | self.Thaw() 36 | 37 | 38 | class FileExplorerPanel(wx.Panel): 39 | def __init__(self, parent): 40 | wx.Panel.__init__(self, parent, wx.ID_ANY) 41 | 42 | resizer = wx.BoxSizer(wx.HORIZONTAL) 43 | # Starting directory for directory view 44 | data_directory = get_data_directory() 45 | 46 | if data_directory is None: 47 | self.file_explorer = wx.GenericDirCtrl(self, wx.ID_ANY) 48 | else: 49 | self.file_explorer = wx.GenericDirCtrl(self, wx.ID_ANY, dir=data_directory) 50 | 51 | resizer.Add(self.file_explorer, 1, wx.EXPAND | wx.ALL) 52 | self.SetSizerAndFit(resizer) 53 | 54 | 55 | class MainForm(wx.Frame): 56 | def __init__(self, *args, **kwargs): 57 | super(MainForm, self).__init__(*args, **kwargs) 58 | 59 | # Menubar 60 | menubar = wx.MenuBar() 61 | file_menu = wx.Menu() 62 | quit_item = file_menu.Append(wx.ID_EXIT, 'Quit', 'Quit application') 63 | self.Bind(wx.EVT_MENU, self.on_quit, quit_item) 64 | 65 | info_menu = wx.Menu() 66 | info_menu.Append(wx.ID_ABOUT, 'About') 67 | info_menu.Bind(wx.EVT_MENU, MainForm.on_about_box) 68 | 69 | menubar.Append(file_menu, '&File') 70 | menubar.Append(info_menu, 'Info') 71 | 72 | self.SetMenuBar(menubar) 73 | 74 | # Content 75 | splitter = wx.SplitterWindow(self) 76 | self.panel1 = FileExplorerPanel(splitter) 77 | self.panel2 = InternalListCtrlPanel(splitter) 78 | 79 | splitter.SplitVertically(self.panel1, self.panel2, -550) 80 | tree = self.panel1.file_explorer.GetTreeCtrl() 81 | self.Bind(wx.EVT_TREE_ITEM_RIGHT_CLICK, self.on_right_click, tree) 82 | self.Bind(wx.EVT_TREE_SEL_CHANGED, self.on_sel_changed, tree) 83 | 84 | # Popup menus 85 | self.popupmenu_unpack = wx.Menu() 86 | self.popupmenu_pack = wx.Menu() 87 | unpack_entry = self.popupmenu_unpack.Append(-1, 'Unpack GFS') 88 | pack_entry = self.popupmenu_pack.Append(-1, 'Pack directory') 89 | pack_entry_aligned = self.popupmenu_pack.Append(-1, 'Pack directory (aligned)') 90 | refresh_tree_pack = self.popupmenu_pack.Append(-1, 'Refresh') 91 | refresh_tree_unpack = self.popupmenu_unpack.Append(-1, 'Refresh') 92 | self.Bind(wx.EVT_MENU, self.on_unpack, unpack_entry) 93 | self.Bind(wx.EVT_MENU, self.on_pack, pack_entry) 94 | self.Bind(wx.EVT_MENU, self.on_pack_aligned, pack_entry_aligned) 95 | self.Bind(wx.EVT_MENU, self.refresh_tree, refresh_tree_pack) 96 | self.Bind(wx.EVT_MENU, self.refresh_tree, refresh_tree_unpack) 97 | self.current_target = None 98 | 99 | # Window 100 | self.SetSize((1000, 550)) 101 | self.SetIcon(SKM2.GetIcon()) 102 | self.SetTitle(APPLICATION_NAME + ' ' + APPLICATION_VERSION) 103 | 104 | def on_quit(self, e): 105 | self.Close() 106 | 107 | def on_pack(self, e): 108 | self.pack(False) 109 | 110 | def on_pack_aligned(self, e): 111 | self.pack(True) 112 | 113 | def pack(self, aligned): 114 | target_export_path = self.current_target + '.gfs' 115 | # Check if there is a directory 116 | if os.path.isdir(target_export_path): 117 | dialog = wx.MessageDialog(self, 'Cannot create .gfs file, there is a directory with the target name', 'Error', style=wx.OK) 118 | dialog.ShowModal() 119 | return 120 | # Check if target file exists and ask for overwrite 121 | if os.path.isfile(target_export_path): 122 | dialog = wx.MessageDialog(self, 'Do you want to overwrite the existing .gfs file?', 'Warning', wx.YES_NO | wx.NO_DEFAULT) 123 | replace = dialog.ShowModal() == wx.ID_YES 124 | if not replace: 125 | return 126 | # Check if total of files is more than 3 GB 127 | file_entries = get_files_in_dir(self.current_target) 128 | if sum(entry.size for entry in file_entries) > 3000000000: 129 | dialog = wx.MessageDialog(self, 'Total data is more than 3 GB, continue?', 'Warning', wx.YES_NO | wx.NO_DEFAULT) 130 | if dialog.ShowModal() == wx.ID_NO: 131 | return 132 | # Write file 133 | write_content(self.current_target, file_entries, aligned) 134 | # Update tree 135 | self.Freeze() 136 | self.panel1.file_explorer.ReCreateTree() 137 | self.panel1.file_explorer.SetPath(target_export_path) 138 | self.Thaw() 139 | 140 | def on_unpack(self, e): 141 | # Check if target file is a valid gfs file 142 | try: 143 | # Get metadata 144 | metadata = get_metadata(self.current_target) 145 | if metadata is None: 146 | raise ValueError('Not a valid .gfs file') 147 | except: 148 | dialog = wx.MessageDialog(self, 'Not a valid .gfs file', 'Error') 149 | dialog.ShowModal() 150 | return 151 | # Make base directory 152 | base_dir = os.path.splitext(self.current_target)[0] 153 | if os.path.exists(base_dir): 154 | if os.path.isdir(base_dir): 155 | dialog = wx.MessageDialog(self, 'Directory already exists, replace files?', 'Question', wx.YES_NO | wx.NO_DEFAULT) 156 | if dialog.ShowModal() == wx.NO: 157 | return 158 | else: 159 | dialog = wx.MessageDialog(self, 'File with target unpack name already exists', 'Error') 160 | dialog.ShowModal() 161 | return 162 | os.makedirs(base_dir, exist_ok=True) 163 | # Unpack 164 | try: 165 | if not export_files(self.current_target, metadata): 166 | raise ValueError('Invalid path or other error') 167 | except: 168 | dialog = wx.MessageDialog(self, 'Error during unpack', 'Error') 169 | dialog.ShowModal() 170 | # Update tree 171 | self.Freeze() 172 | self.panel1.file_explorer.ReCreateTree() 173 | self.panel1.file_explorer.SetPath(base_dir) 174 | self.Thaw() 175 | 176 | def refresh_tree(self, e): 177 | path = self.panel1.file_explorer.GetPath() 178 | file = self.panel1.file_explorer.GetFilePath() 179 | self.Freeze() 180 | self.panel1.file_explorer.ReCreateTree() 181 | if path != '': 182 | self.panel1.file_explorer.SetPath(path) 183 | if file != '': 184 | self.panel1.file_explorer.SetPath(file) 185 | self.Thaw() 186 | 187 | @staticmethod 188 | def on_about_box(e): 189 | about_dialog_info = wx.adv.AboutDialogInfo() 190 | about_dialog_info.SetIcon(SKM2.GetIcon()) 191 | about_dialog_info.SetName(APPLICATION_NAME) 192 | about_dialog_info.SetVersion(APPLICATION_VERSION + ' ' + APPLICATION_DATE) 193 | about_dialog_info.SetDescription('Unofficial modding tool for Skullgirls') 194 | about_dialog_info.SetLicense(APPLICATION_LICENSE) 195 | wx.adv.AboutBox(about_dialog_info) 196 | 197 | def on_right_click(self, event): 198 | # Select element before triggering menu 199 | tree_ctrl = self.panel1.file_explorer.GetTreeCtrl() 200 | selected_item, _ = tree_ctrl.HitTest(event.GetPoint()) 201 | tree_ctrl.SelectItem(selected_item) 202 | 203 | if self.panel1.file_explorer.GetFilePath() == '': 204 | if self.panel1.file_explorer.GetPath() == '': 205 | return 206 | else: 207 | self.current_target = self.panel1.file_explorer.GetPath() 208 | else: 209 | self.current_target = self.panel1.file_explorer.GetFilePath() 210 | 211 | if os.path.isfile(self.current_target): 212 | self.PopupMenu(self.popupmenu_unpack, event.GetPoint()) 213 | 214 | # Don't allow the root of a partition to be selected 215 | if os.path.isdir(self.current_target) and os.path.dirname(self.current_target) != self.current_target: 216 | self.PopupMenu(self.popupmenu_pack, event.GetPoint()) 217 | 218 | def on_sel_changed(self, event): 219 | if not self.panel1.file_explorer.IsBeingDeleted(): 220 | table = self.panel2.internal_grid_string_table 221 | table.set_data(get_metadata(self.panel1.file_explorer.GetFilePath())) 222 | -------------------------------------------------------------------------------- /SkullModPy2/util.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import struct 4 | import sys 5 | 6 | try: 7 | import winreg 8 | except ImportError: 9 | # Non-Windows platform, handled in methods 10 | pass 11 | 12 | 13 | # Registry 14 | def get_reg_key_win(path, name): 15 | registry_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_READ) 16 | value, regtype = winreg.QueryValueEx(registry_key, name) 17 | winreg.CloseKey(registry_key) 18 | return value 19 | 20 | 21 | # Steam 22 | def get_steam_dir_win(): 23 | try: 24 | # Get 64-bit steam dir 25 | return get_reg_key_win('SOFTWARE\\WOW6432Node\\Valve\\Steam', 'InstallPath') 26 | except OSError: 27 | try: 28 | # Get 32-bit steam dir 29 | return get_reg_key_win('SOFTWARE\\Valve\\Steam', 'InstallPath') 30 | except OSError: 31 | return None 32 | 33 | 34 | # Naive parsing of libraryfolders.vdf 35 | def parse_libraryfolders_vdf_win(steam_dir): 36 | libraryfolders_path = os.path.join(steam_dir, 'steamapps', 'libraryfolders.vdf') 37 | library_dirs = [steam_dir] 38 | 39 | with open(libraryfolders_path, 'r') as lf_file: 40 | for line in lf_file.readlines(): 41 | if not line.lstrip().startswith('"path"'): 42 | continue 43 | # Get key and value 44 | segments = line.split('"') 45 | if len(segments) != 5: 46 | continue 47 | new_steam_dir = segments[3].replace('\\\\', '\\') 48 | if new_steam_dir not in library_dirs: 49 | library_dirs.append(new_steam_dir) 50 | return library_dirs 51 | 52 | 53 | def get_data_directory(): 54 | if sys.platform == 'darwin': # MacOS 55 | # Return guessed default location for MacOS, path is blind guess based on PR from GitHub 56 | return '~/Library/Application Support/Steam/steamapps/common/Skullgirls/data01' 57 | steam_dir = get_steam_dir_win() 58 | if steam_dir is None: 59 | return None 60 | steam_libraries_list = parse_libraryfolders_vdf_win(steam_dir) 61 | # Try to find the steam library with the game 62 | for steam_library in steam_libraries_list: 63 | if os.path.exists(os.path.join(steam_library, 'steamapps', 'appmanifest_245170.acf')): 64 | return os.path.join(steam_library, 'steamapps', 'common', 'Skullgirls', 'data01') 65 | return None 66 | 67 | 68 | def read_pascal_string(file) -> str: 69 | length = read_int(file, 8, False) 70 | if length < 0: 71 | raise ValueError('String length has to be 0 or more') 72 | return file.read(length).decode('ascii') 73 | 74 | 75 | def write_pascal_string(file, string: str): 76 | ascii_string = string.encode('ascii') 77 | file.write(struct.pack('>Q', len(ascii_string))) 78 | file.write(ascii_string) 79 | 80 | 81 | def read_int(file, length, signed) -> int: 82 | if length not in [4, 8]: 83 | raise ValueError('int length can only be 4 or 8') 84 | unpack_char = 'i' if length == 4 else 'q' 85 | if not signed: 86 | unpack_char = str.capitalize(unpack_char) 87 | 88 | return struct.unpack('>' + unpack_char, file.read(length))[0] 89 | 90 | 91 | def human_readable_file_size(size_in_bytes: int) -> str: 92 | if size_in_bytes == 0: 93 | return '0B' 94 | size_name = ('B', 'KiB', 'MiB', 'GiB') 95 | entry_index = int(math.floor(math.log(size_in_bytes, 1024))) 96 | if entry_index == 0: 97 | number = size_in_bytes 98 | else: 99 | number = round(size_in_bytes / math.pow(1024, entry_index), 1) 100 | return '%s %s' % (number, size_name[entry_index]) 101 | -------------------------------------------------------------------------------- /app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xFAIL/SkullMod2/7ec3eb7cd88d0cf3174295b34e26e2967367035e/app.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wxPython~=4.1.1 --------------------------------------------------------------------------------