#!/usr/bin/env python3 """ Fallout Shelter Save Editor - PyQt GUI A comprehensive PyQt-based tool for editing Fallout Shelter save files. """ import sys import os import json import base64 import zlib import gzip import struct import shutil from datetime import datetime from typing import Dict, Any, Optional, List, Tuple import traceback from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QTextEdit, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox, QGroupBox, QGridLayout, QSpinBox, QDoubleSpinBox, QScrollArea, QFrame, QSplitter, QTreeWidget, QTreeWidgetItem, QHeaderView, QComboBox, QCheckBox, QProgressBar, QStatusBar, QMenuBar, QAction, QToolBar, QTableWidget, QTableWidgetItem ) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer from PyQt5.QtGui import QFont, QIcon, QPixmap class SaveDecryptionWorker(QThread): """Worker thread for save file decryption/analysis.""" progress_updated = pyqtSignal(int) status_updated = pyqtSignal(str) finished_signal = pyqtSignal(dict) error_signal = pyqtSignal(str) def __init__(self, filepath: str): super().__init__() self.filepath = filepath def run(self): """Run the decryption process.""" try: self.status_updated.emit("Loading save file...") self.progress_updated.emit(10) with open(self.filepath, 'rb') as f: raw_data = f.read() self.status_updated.emit("Decoding base64...") self.progress_updated.emit(20) decoded_data = base64.b64decode(raw_data) self.status_updated.emit("Attempting decompression...") self.progress_updated.emit(40) # Try various decompression methods result = self.try_decompress(decoded_data) if result: self.status_updated.emit("Save file loaded successfully!") self.progress_updated.emit(100) self.finished_signal.emit(result) else: self.status_updated.emit("Could not decrypt save file") self.error_signal.emit("Unable to decrypt or decompress save file") except Exception as e: self.error_signal.emit(f"Error loading save: {str(e)}") def try_decompress(self, data: bytes) -> Optional[Dict]: """Try various decompression methods.""" methods = [ ("Raw JSON", self.try_raw_json), ("Zlib", self.try_zlib), ("Gzip", self.try_gzip), ("Custom Format", self.try_custom_format), ] for i, (name, method) in enumerate(methods): self.status_updated.emit(f"Trying {name}...") self.progress_updated.emit(40 + (i * 15)) try: result = method(data) if result: return { "method": name, "data": result, "raw_size": len(data) } except Exception as e: continue return None def try_raw_json(self, data: bytes) -> Optional[Dict]: """Try parsing as raw JSON.""" text = data.decode('utf-8') return json.loads(text) def try_zlib(self, data: bytes) -> Optional[Dict]: """Try zlib decompression.""" decompressed = zlib.decompress(data) text = decompressed.decode('utf-8') return json.loads(text) def try_gzip(self, data: bytes) -> Optional[Dict]: """Try gzip decompression.""" decompressed = gzip.decompress(data) text = decompressed.decode('utf-8') return json.loads(text) def try_custom_format(self, data: bytes) -> Optional[Dict]: """Try custom Fallout Shelter format.""" # Try with header if len(data) > 4: header = struct.unpack(' bool: """Write the save data to file.""" try: # Get current data from JSON editor json_text = self.json_editor.toPlainText() data = json.loads(json_text) # Convert to JSON string json_string = json.dumps(data, separators=(',', ':')) # Compress (try to match original method) if self.save_method == "Zlib": compressed = zlib.compress(json_string.encode('utf-8')) else: # Default to zlib compressed = zlib.compress(json_string.encode('utf-8')) # Encode to base64 encoded = base64.b64encode(compressed) # Write to file with open(filepath, 'wb') as f: f.write(encoded) return True except Exception as e: print(f"Error writing save file: {e}") traceback.print_exc() return False def create_backup(self): """Create a backup of the current save file.""" if not self.save_filepath: QMessageBox.warning(self, "Warning", "No save file loaded") return timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = f"{self.save_filepath}.backup_{timestamp}" try: shutil.copy2(self.save_filepath, backup_path) QMessageBox.information(self, "Success", f"Backup created:\n{os.path.basename(backup_path)}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to create backup:\n{e}") def refresh_data(self): """Refresh all data displays.""" self.populate_data() def update_ui_state(self): """Update UI element states based on current state.""" has_save = self.save_data is not None self.save_btn.setEnabled(has_save and self.is_modified) self.backup_btn.setEnabled(has_save) # Update window title title = "Fallout Shelter Save Editor" if self.save_filepath: filename = os.path.basename(self.save_filepath) title += f" - {filename}" if self.is_modified: title += " *" self.setWindowTitle(title) def show_about(self): """Show about dialog.""" QMessageBox.about( self, "About Fallout Shelter Save Editor", "Fallout Shelter Save Editor v1.0\n\n" "A PyQt-based tool for editing Fallout Shelter save files.\n\n" "Features:\n" "• Load and save encrypted save files\n" "• Edit vault information and resources\n" "• View and manage dwellers\n" "• Raw JSON editing\n" "• Automatic backups" ) def closeEvent(self, event): """Handle window close event.""" if self.is_modified: reply = QMessageBox.question( self, "Unsaved Changes", "You have unsaved changes. Do you want to save before closing?", QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel ) if reply == QMessageBox.Save: self.save_file() event.accept() elif reply == QMessageBox.Discard: event.accept() else: event.ignore() else: event.accept() def main(): """Main function.""" app = QApplication(sys.argv) app.setApplicationName("Fallout Shelter Save Editor") app.setApplicationVersion("1.0") # Set application style app.setStyle('Fusion') # Create and show main window window = FalloutShelterSaveEditor() window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()