import sys from PyQt5.QtWidgets import * from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5 import QtGui, QtCore, QtWidgets from threading import Thread from queue import Queue from construct import * from glob import glob import os ScrapFile = Struct( 'path'/PrefixedArray(Int32ul, Byte), 'size'/Int32ul, 'offset'/Int32ul, ) PackedFile = Struct( Const(b'BFPK'), Const(b'\0\0\0\0'), 'files'/PrefixedArray(Int32ul, ScrapFile), ) def get_path(file): return str(bytes(file.path), "utf-8", "backslashreplace") def fsize(n): idx = 0 l = ["", "K", "M", "G", "T", "P", "E"] while n > 1024: idx += 1 n /= 1024 return "{:.02f} {}B".format(n, l[idx]) class Tree(dict): def __init__(self, *args, **kwargs): self._size = 0 self.data = None self.path = None super(type(self), self).__init__(*args, **kwargs) def __missing__(self, k): self[k] = type(self)() return self[k] @property def size(self): if self.data: ret = self.data.size else: ret = 0 for v in self.values(): ret += v.size return ret @size.setter def size(self, s): self._size = s def load_data(path): ret = Tree() fn = os.path.split(path)[-1] ret[fn] = Tree() with open(path, "rb") as fh: data = PackedFile.parse_stream(fh) for entry in data.files: path = get_path(entry).split("/") root = ret[fn] for elem in path: root = root[elem] root.data = entry return ret class Patcher(object): def __init__(self, ed): self.editor = ed def debug(self, *args): "Enable Scengraph Debugging Console" print("DBG") def test(self, *args): "TEST!" print(self.editor.selected()) pass class PackerThread(QThread): signal = pyqtSignal('PyQt_PyObject') def __init__(self, node, path): super().__init__() self.node = node self.path = path def func(self, *args, **kwargs): self.signal.emit(*args, **kwargs) def run(self): try: self.__run() except: (type, value, traceback) = sys.exc_info() print(type, value, traceback) sys.excepthook(type, value, traceback) def __run(self): node = self.node path = self.path PAK = node.pak file_list = [] self.func(("wtl", "Preprocessing")) self.func(("range", 0, 0)) for item in walk(node): if item.struct: file_list.append(item) self.func(("upd", get_loc(item)[1], 1)) header = PackedFile.build(dict(files=[f.struct for f in file_list])) total_size = len(header) self.func(("wtl", "Updating offsets")) self.func(("range", 0, len(file_list))) for idx, file in enumerate(file_list): file.struct.offset = total_size total_size += file.struct.size self.func(("upd", get_loc(file)[1], idx)) header = PackedFile.build(dict(files=[f.struct for f in file_list])) cnt = 0 if path: self.func(("wtl", "Writing output")) self.func(("range", 0, total_size)) with open(path, "wb") as of: of.write(header) written = len(header) self.func(("upd", "Header", written)) for file in file_list: loc = get_loc(file) data = get_data(file)[0] written += len(data) self.func(("upd", get_loc(file)[1], written)) of.write(data) print("XXXXXXXXXXXXXXXXXXXXX") self.func(("close",)) return class PackerWindow(QProgressDialog): def __init__(self, node, path, *args, **kwargs): self.node = node self.path = path self.mtx = QMutex() super().__init__() self.initUI() self.setAutoClose(True) self.setWindowTitle("Packing {}".format(node.pak)) self.setCancelButtonText("Stop") self.run() def initUI(self): self.show() self.ensurePolished() self.setFixedSize(300, self.height()) def closeEvent(self, event): print("BYE") if self.th: self.th.wait() event.accept() def sig(self, val): lock = QMutexLocker(self.mtx) #print("SIG:", val) typ, *args = val if typ == "wtl": self.setWindowTitle(*args) elif typ == "close": self.th.wait() self.th = None self.close() elif typ == "range": self.setRange(*args) elif typ == "upd": self.setLabelText(args[0]) self.setValue(args[1]) else: raise ValueError("Unknown signal: {}".format(val)) def run(self): self.queue = Queue() self.th = PackerThread(self.node, self.path) self.th.signal.connect(self.sig) self.th.start() class MainWindow(QMainWindow): def __init__(self, parent=None): super(MainWindow, self).__init__(parent) self.setWindowTitle("SMT - Scrap Mod Tool") self.resize(800, 600) self.center() menu_close_all = QAction("Close &All", self) menu_close_all.setShortcut("Ctrl+Shift+C") menu_close_all.triggered.connect(self.menu_close_all) menu_create = QAction("&Create new .packed file", self) menu_create.setShortcut("Ctrl+N") menu_create.triggered.connect(self.menu_new) menu_load = QAction("&Open .packed file", self) menu_load.setShortcut("Ctrl+O") menu_load.triggered.connect(self.menu_load) menu_exit = QAction("&Exit", self) menu_exit.setShortcut("Ctrl+Q") menu_exit.triggered.connect(qApp.quit) menu = self.menuBar() fileMenu = menu.addMenu('&File') fileMenu.addAction(menu_load) fileMenu.addAction(menu_create) fileMenu.addSeparator() fileMenu.addAction(menu_close_all) fileMenu.addSeparator() fileMenu.addAction(menu_exit) self.editor = PackedEditor(self) self.setCentralWidget(self.editor) self.show() self.menu_load(sys.argv[1:]) def menu_new(self): data = PackedFile.build({'files': []}) loc = QFileDialog.getSaveFileName( self, 'Save file', os.getcwd(), "Scrapland Data Files (*.packed)")[0] with open(loc, "wb") as of: of.write(data) self.editor.clear() self.editor.load(loc) def menu_load(self, paths=None): if not paths: paths = QFileDialog.getOpenFileNames( self, 'Open file', os.getcwd(), "Scrapland Data Files (*.packed)")[0] for path in paths: path = os.path.abspath(path) self.editor.load(path) def menu_close_all(self): self.editor.clear() def center(self): qr = self.frameGeometry() cp = QDesktopWidget().availableGeometry().center() qr.moveCenter(cp) self.move(qr.topLeft()) def get_loc(item): node = item path = [] while node: path.insert(0, node.text(0)) node = node.parent() if path: file = path.pop(0) else: file = None path = "/".join(path) return (file, path) def walk(node): yield node for child_idx in range(node.childCount()): yield from walk(node.child(child_idx)) def read_file(path, size=None, offset=None): data = None with open(path, "rb") as fh: if offset: fh.seek(offset) data = fh.read(size) return data def get_data(node, size=None): bin_data = None more = None if node.struct: if size: if node.struct.size > size: more = True size = min(node.struct.size, size) else: size = node.struct.size if node.path: bin_data = read_file(node.path, size) else: bin_data = read_file(node.pak, size, node.struct.offset) return bin_data, more def hexdump(data, addr=0): res = [] bin_data = list(data) while bin_data: chunk = bin_data[:16] bin_data = bin_data[16:] res.append("0x{:04x} | {}".format( addr, " ".join("{:02x}".format(b) for b in chunk))) addr += len(chunk) return "\n".join(res) def build_tree(node, url): pak = get_loc(node)[0] subtree = Tree() path = url.toLocalFile().replace("/", "\\") if os.path.isfile(path): full_path = os.path.abspath(path) path, file = os.path.split(full_path) loc = os.path.join(get_loc(node)[1], file) root_node = subtree[pak] for elem in loc.split("/"): root_node = root_node[elem] root_node.data = Container(path=bytes(loc, "utf-8"), size=os.path.getsize( full_path), offset=0) root_node.size = root_node.data.size root_node.path = full_path else: for root, _, files in os.walk(path): root = root.replace("/", os.sep) for file in files: full_path = os.path.join(root, file) loc = full_path.replace(path.replace( "/", os.sep), "").strip(os.sep) loc = os.path.join(os.path.split( path)[-1], loc).replace(os.sep, "/") root_node = subtree[pak] for elem in loc.split("/"): root_node = root_node[elem] root_node.data = Container(path=bytes(loc, "utf-8"), size=os.path.getsize( full_path), offset=0) root_node.size = root_node.data.size root_node.path = full_path return subtree[pak] class DataTree(QTreeWidget): def dragEnterEvent(self, event): m = event.mimeData() if m.hasUrls(): for url in m.urls(): if url.isLocalFile(): event.accept() return event.accept() def dragMoveEvent(self, event): if event.mimeData().hasUrls: event.setDropAction(QtCore.Qt.CopyAction) else: event.setDropAction(QtCore.Qt.MoveAction) event.accept() def dropEvent(self, event): editor = self.window().editor target = self.itemAt(event.pos()) if target.struct: return print("Drop: ", get_loc(target)) if event.source(): selected = event.source().selectedItems() for item in selected: labels = [item.text(i) for i in range(item.columnCount())] node = QTreeWidgetItem(target, labels) node.struct = item.struct node.struct.offset = 0 node.path = item.path node.pak = item.pak else: m = event.mimeData() if m.hasUrls(): for url in m.urls(): tree = build_tree(target, url) editor.make_subtree(target, tree, target.pak) editor.update_tree() event.accept() class DataLoader(QThread): signal = pyqtSignal('PyQt_PyObject') def __init__(self, path, subtree=None): super().__init__() self.path = path def run(self): self.signal.emit((self.path, load_data(self.path))) class DataExtractor(QThread): signal = pyqtSignal('PyQt_PyObject') def __init__(self, nodes, dest): super().__init__() self.dest = dest self.nodes = nodes def run(self): for node in self.nodes: for ch in walk(node): if ch.struct: self.__extract(ch, self.dest) self.signal.emit("Done!") return def __extract(self, node, dest): path = get_path(node.struct) self.signal.emit("Extracting {}".format(path)) folder, file = os.path.split(path) folder = os.path.join(dest, folder) folder = folder.replace("/", os.sep).replace("\\", os.sep) file = os.path.join(folder, file) os.makedirs(folder, exist_ok=True) with open(node.pak, "rb") as pak: with open(file, "wb") as of: pak.seek(node.struct.offset) of.write(pak.read(node.struct.size)) class PackedEditor(QWidget): def __init__(self, parent=None): super(PackedEditor, self).__init__(parent) self.initUI() self.data = {} self.threads = [] self.log_mutex = QMutex() def initUI(self): self.initLayout() self.show() def initLayout(self): grid = QGridLayout() hgrid = QSplitter(Qt.Horizontal) vgrid = QSplitter(Qt.Vertical) patcher_grid = QGridLayout() self.info_tab_hex = QTextEdit() self.info_tab_text = QTextEdit() self.info_tab_info = QTextEdit() self.info_tabs = QTabWidget() self.info_tab_hex.setReadOnly(True) self.info_tab_text.setReadOnly(True) self.info_tab_info.setReadOnly(True) self.info_tabs.addTab(self.info_tab_info, "Info") self.info_tabs.addTab(self.info_tab_text, "Text") self.info_tabs.addTab(self.info_tab_hex, "Hexdump") self.util_tabs = QTabWidget() self.util_tab_log = QTextEdit() self.util_tab_log.setReadOnly(True) self.util_tab_patch = QWidget() self.util_tab_patch.setLayout(patcher_grid) self.util_tabs.addTab(self.util_tab_log, "Log") self.util_tabs.addTab(self.util_tab_patch, "Patcher") self.patcher = Patcher(self) i = 0 patcher_grid_w = 0 for func in dir(self.patcher): if func.startswith("__") or not hasattr(getattr(self.patcher, func), "__call__"): continue patcher_grid_w += 1 patcher_grid_w = int(patcher_grid_w**0.5) for func in dir(self.patcher): if func.startswith("__") or not hasattr(getattr(self.patcher, func), "__call__"): continue func = getattr(self.patcher, func) x, y = divmod(i, patcher_grid_w) button = QPushButton(func.__name__.replace("_", " ").title()) button.setToolTip(func.__doc__) button.clicked.connect(func) patcher_grid.addWidget(button, x, y) i += 1 self.tree = DataTree() self.tree.setHeaderLabels(["Path", "Size", "Offset"]) self.tree.setSelectionMode(self.tree.ExtendedSelection) self.tree.currentItemChanged.connect(self.tree_changed) self.tree.setDragDropMode(self.tree.DragDrop | self.tree.InternalMove) self.tree.setDragEnabled(True) self.tree.setAcceptDrops(True) self.tree.setDropIndicatorShown(True) hgrid.addWidget(self.tree) hgrid.addWidget(self.info_tabs) vgrid.addWidget(hgrid) vgrid.addWidget(self.util_tabs) grid.addWidget(vgrid) self.setLayout(grid) def info(self, msg): lock = QMutexLocker(self.log_mutex) self.util_tab_log.insertPlainText(msg + "\n") self.util_tab_log.moveCursor(QTextCursor.End) def load(self, path): if path in self.data: return self.data[path] = DataLoader(path) self.data[path].signal.connect(self.done_loading) self.data[path].start() self.info("Loading {}".format(path)) def done_loading(self, result): path, data = result self.data[path] = data self.make_subtree(self.tree, data, path) def clear(self): self.data.clear() self.tree.clear() self.info_tab_hex.setText("") self.info_tab_text.setText("") self.info_tab_info.setText("") self.util_tab_log.setText("") def make_tree(self): for pak in self.data: if isinstance(self.data[pak], DataLoader): continue self.make_subtree(self.tree, self.data[pak], pak) def update_tree(self, node=None): total_size = 0 if node is None: node = self.tree.invisibleRootItem() else: if node.struct: total_size += node.struct.size for child_idx in range(node.childCount()): total_size += self.update_tree(node.child(child_idx)) node.setText(1, fsize(total_size)) return total_size @classmethod def make_subtree(cls, tree, data, pak): total_size = 0 for name, children in sorted(data.items()): total_size += children.size if children.data: labels = [name, fsize(children.size), hex(children.data.offset)] else: labels = [name, fsize(children.size), ""] node = QTreeWidgetItem(tree, labels) node.struct = children.data node.path = children.path node.pak = pak cls.make_subtree(node, children, pak) def contextMenuEvent(self, event): cmenu = QMenu(self) actions = {} actions[cmenu.addAction("Extract")] = self.extract_handler actions[cmenu.addAction("Delete")] = self.del_handler if all(node.parent() == None for node in self.selected()): actions[cmenu.addAction("Save")] = self.save_handler try: act_fn = actions.get( cmenu.exec_(self.mapToGlobal(event.pos()))) except TypeError: return if not act_fn: return th = act_fn(self.selected()) if th: th.signal.connect(self.info) th.start() self.threads.append(th) def extract_handler(self, nodes): dest = QFileDialog.getExistingDirectory( self, 'Open file', os.getcwd()) if not nodes: return if not dest: return return DataExtractor(nodes, dest) def make_packed(self, node, path=None): a = PackerWindow(node, path) a.exec_() def save_handler(self, nodes): for node in nodes: path = QFileDialog.getSaveFileName( self, 'Save file', node.pak, "Scrapland Data Files (*.packed)")[0] self.make_packed(node, path) def del_handler(self, nodes): if not nodes: return root = self.tree.invisibleRootItem() for node in nodes: (node.parent() or root).removeChild(node) self.update_tree() def get_info(self, node): info = [] if node.struct: info.append(("Size", node.struct.size)) info.append(("Offset", hex(node.struct.offset))) info.append(("Path", get_path(node.struct))) ret = "" for k, v in info: ret += "{}:\t{}\n".format(k, v) return ret.strip() def tree_changed(self, new, old): if not new: return self.info_tab_text.setText("") self.info_tab_hex.setText("") self.info_tab_info.setText(self.get_info(new)) data, more = get_data(new, 1024) if data: more_text = "\n<...>" if more else "" self.info_tab_hex.setText(hexdump(data)+more_text) try: data = str(data, "cp1252") except UnicodeDecodeError: data = repr(data) self.info_tab_text.setText(data+more_text) self.info_tab_info.setText(self.get_info(new)) def selected(self): return self.tree.selectedItems() def main(): app = QApplication(sys.argv) win = MainWindow() win.show() sys.exit(app.exec_()) if __name__ == '__main__': main()