class MainWidget(QWidget): after_process_input_gci = pyqtSignal() def __init__(self): super().__init__() self.init_layout() self.output_filename_defaults = dict( gci='', replay_array='', ) self.worker_thread = None self.after_process_input_gci.connect(self._after_process_input_gci) # Accept drag and drop; see event methods for details self.setAcceptDrops(True) def init_layout(self): self.error_label = QLabel("") self.error_label.setStyleSheet("QLabel { color: red; }") self.input_gci_label = QLabel("Drag and drop a .gci to get started") # Given that we have word wrap True, setting a fixed height prevents # the GUI from resizing (and prevents an associated window size warning # in the console window) when we switch between long and short # filepaths. # To help with reading long filepaths, we'll also set a tooltip each # time we set the filepath. self.input_gci_label.setWordWrap(True) self.input_gci_label.setMinimumWidth(150) self.input_gci_label.setFixedHeight(35) self.input_gci_checksum_label = QLabel("") self.gci_fields_widget = GCIFieldsWidget(self) self.output_button = QPushButton("Output", self) self.output_button.clicked.connect(self.on_output_button_click) self.output_type_combo_box = QComboBox(self) # The choices here only apply to replays. If we handle other types of # .gci's in the future, we might change this combo box to add its # choices dynamically. self.output_type_combo_box.addItems([".gci", "Replay array .bin"]) self.output_type_combo_box.activated[str].connect( self.on_output_type_change) self.output_folder_select_dialog = QFileDialog( self, "Choose folder to save to", self.output_folder) self.output_folder_label = QLabel(self.output_folder) self.output_folder_label.setToolTip(self.output_folder) self.output_folder_label.setWordWrap(True) self.output_folder_label.setMinimumWidth(150) self.output_folder_label.setFixedHeight(50) self.output_folder_select_button = QPushButton("Choose", self) self.output_folder_select_button.clicked.connect( self.show_output_folder_select_dialog) self.output_filename_line_edit = QLineEdit() self.output_filename_line_edit.editingFinished.connect( self.on_output_filename_change) output_type_hbox = QHBoxLayout() output_type_hbox.addWidget(self.output_button) output_type_hbox.addWidget(self.output_type_combo_box) output_folder_hbox = QHBoxLayout() output_folder_hbox.addWidget(QLabel("to folder:")) output_folder_hbox.addWidget(self.output_folder_label) output_folder_hbox.addWidget(self.output_folder_select_button) output_filename_hbox = QHBoxLayout() output_filename_hbox.addWidget(QLabel("with filename:")) output_filename_hbox.addWidget(self.output_filename_line_edit) self.output_vbox = QVBoxLayout() self.output_vbox.addLayout(output_type_hbox) self.output_vbox.addLayout(output_folder_hbox) self.output_vbox.addLayout(output_filename_hbox) self.output_vbox_widget = QWidget() self.output_vbox_widget.setLayout(self.output_vbox) self.output_vbox_widget.hide() vbox = QVBoxLayout() vbox.addWidget(self.error_label) vbox.addWidget(self.input_gci_label) vbox.addWidget(self.input_gci_checksum_label) vbox.addWidget(self.gci_fields_widget) vbox.addWidget(self.output_vbox_widget) self.setLayout(vbox) self.setWindowTitle("F-Zero GX GCI info") self.show() def clear_error_display(self): self.error_label.setText("") def display_error(self, message): self.error_label.setText(message) def show_output_folder_select_dialog(self): folder = self.output_folder_select_dialog.getExistingDirectory( self, 'Choose folder') if folder: self.output_folder_label.setText(folder) self.output_folder_label.setToolTip(folder) config.set('output_folder', folder) def run_worker_job(self, job_func): if not self.worker_thread: # Create a thread. # Must store a reference to the thread in a non-local variable, # so the thread doesn't get garbage collected after returning # from this method # https://stackoverflow.com/a/15702922/ self.worker_thread = WorkerThread() # This will emit 'started' and start running the thread self.worker_thread.run_job(job_func) def closeEvent(self, e): """Event handler: GUI window is closed""" config.save() def dragEnterEvent(self, e): """Event handler: Mouse enters the GUI window while dragging something""" e.accept() def dropEvent(self, e): """ Event handler: Mouse is released on the GUI window after dragging. Check that we've dragged a single .gci file. If so, process it. """ self.clear_error_display() mime_data = e.mimeData() dropped_uris = [uri.toString() for uri in mime_data.urls()] if len(dropped_uris) > 1: self.display_error("Please drag and drop a single file.") return uri = urllib.parse.urlparse(dropped_uris[0]) uri_scheme = uri.scheme if uri_scheme != 'file': self.display_error( "Please drag and drop a file from your computer.") return # url2pathname() will: # - Replace URL-like escape codes such as %E3%83%BB # with unescaped Unicode characters. # - Strip the beginning slash if it's a Windows filepath. # (/D:/Games/... -> D:\Games\...) input_gci_filepath = Path(urllib.request.url2pathname(uri.path)) if input_gci_filepath.suffix != '.gci': self.display_error("The dropped file doesn't seem to be a .gci.") return self.input_gci_filepath = input_gci_filepath self.process_input_gci() def process_input_gci(self): self.input_gci_label.setText("Working...") self.input_gci = gci(self.input_gci_filepath) input_data = self.input_gci.get_replay_data() Field.reset_field_structures() success = self.gci_fields_widget.read_fields_from_spec() if not success: self.input_gci_label.setText("Drag and drop a .gci to get started") return # Process the replay-specific GCI contents. # This call can take a while, especially with custom machines. self.run_worker_job( functools.partial(self.gci_fields_widget.read_values_from_gci, input_data)) @pyqtSlot() def _after_process_input_gci(self): self.gci_fields_widget.add_field_widgets() self.input_gci_label.setText(f"Input: {self.input_gci_filepath}") self.input_gci_label.setToolTip(f"Input: {self.input_gci_filepath}") self.input_gci_checksum_label.setText( f"\nChecksum: 0x{self.input_gci.get_checksum().hex()}") self.output_filename_defaults = dict( gci='output.gci', replay_array=f'{self.input_gci_filepath.stem}__replay_array.bin', ) self.output_vbox_widget.show() self.on_output_type_change() @property def output_type(self): combo_box_text = self.output_type_combo_box.currentText() if combo_box_text == ".gci": return 'gci' elif combo_box_text == "Replay array .bin": return 'replay_array' @property def output_folder(self): return config.get('output_folder', '') @property def output_filename(self): fn = config.get(f'output_filename_{self.output_type}') if not fn: fn = self.output_filename_defaults[self.output_type] return fn @property def output_filepath(self): return Path(self.output_folder, self.output_filename) def on_output_type_change(self): output_filename = config.get(f'output_filename_{self.output_type}', '') self.output_filename_line_edit.setText(output_filename) self.output_filename_line_edit.setPlaceholderText( self.output_filename_defaults[self.output_type]) def on_output_filename_change(self): config.set(f'output_filename_{self.output_type}', self.output_filename_line_edit.text()) def on_output_button_click(self): self.clear_error_display() if not self.output_folder: self.display_error("Please select an output folder.") return if self.output_type == 'gci': self.output_gci() elif self.output_type == 'replay_array': self.output_replay_array() def output_gci(self): # Re-encode the replay data new_replay_data = self.gci_fields_widget.output_values_to_gci() # Zero-pad to make the entire GCI a multiple of 0x2000 bytes + 0x40. # This replay data does not include the first 0x20A0 bytes of the GCI. gci_bytes_without_padding = len(new_replay_data) + 0x20A0 gci_target_blocks = math.ceil( (gci_bytes_without_padding - 0x40) / 0x2000) gci_target_bytes = (gci_target_blocks * 0x2000) + 0x40 zero_padding = [0] * (gci_target_bytes - gci_bytes_without_padding) new_replay_data.extend(zero_padding) # Concatenate new replay data to the rest of the original GCI self.input_gci.set_replay_data(new_replay_data) # Recompute the checksum of the whole GCI self.input_gci.recompute_checksum() # Write the new GCI to a file self.output_binary_file(self.input_gci.raw_bytes) # Success dialog button_box = QDialogButtonBox(QDialogButtonBox.Ok) vbox = QVBoxLayout() vbox.addWidget( QLabel(f"GCI written to: {self.output_filepath}" f"\nChecksum: 0x{self.input_gci.get_checksum().hex()}")) vbox.addWidget(button_box) confirmed_dialog = QDialog(self) confirmed_dialog.setWindowTitle("Write success") confirmed_dialog.setLayout(vbox) # Make the OK button 'accept' the dialog button_box.accepted.connect(confirmed_dialog.accept) # Show the dialog confirmed_dialog.exec() def output_replay_array(self): self.display_error("Replay-array output mode isn't implemented yet.") def output_binary_file(self, bytes_to_write): with open(self.output_filepath, "wb") as output_file: output_file.write(bytes_to_write)