823 lines
29 KiB
Python
823 lines
29 KiB
Python
#!/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('<I', data[:4])[0]
|
|
if 4 < header < len(data):
|
|
compressed_data = data[4:]
|
|
decompressed = zlib.decompress(compressed_data)
|
|
text = decompressed.decode('utf-8')
|
|
return json.loads(text)
|
|
return None
|
|
|
|
|
|
class FalloutShelterSaveEditor(QMainWindow):
|
|
"""Main application window."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.save_data = None
|
|
self.save_filepath = None
|
|
self.save_method = None
|
|
self.is_modified = False
|
|
|
|
self.init_ui()
|
|
self.setup_connections()
|
|
|
|
def init_ui(self):
|
|
"""Initialize the user interface."""
|
|
self.setWindowTitle("Fallout Shelter Save Editor")
|
|
self.setGeometry(100, 100, 1200, 800)
|
|
|
|
# Create menu bar
|
|
self.create_menu_bar()
|
|
|
|
# Create toolbar
|
|
self.create_toolbar()
|
|
|
|
# Create status bar
|
|
self.status_bar = QStatusBar()
|
|
self.setStatusBar(self.status_bar)
|
|
|
|
# Progress bar for status bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setVisible(False)
|
|
self.status_bar.addPermanentWidget(self.progress_bar)
|
|
|
|
# Central widget
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
|
|
# Main layout
|
|
main_layout = QVBoxLayout(central_widget)
|
|
|
|
# File info section
|
|
self.create_file_info_section(main_layout)
|
|
|
|
# Main content area with tabs
|
|
self.create_main_content(main_layout)
|
|
|
|
# Update UI state
|
|
self.update_ui_state()
|
|
|
|
def create_menu_bar(self):
|
|
"""Create the menu bar."""
|
|
menubar = self.menuBar()
|
|
|
|
# File menu
|
|
file_menu = menubar.addMenu('File')
|
|
|
|
open_action = QAction('Open Save File', self)
|
|
open_action.setShortcut('Ctrl+O')
|
|
open_action.triggered.connect(self.open_save_file)
|
|
file_menu.addAction(open_action)
|
|
|
|
save_action = QAction('Save', self)
|
|
save_action.setShortcut('Ctrl+S')
|
|
save_action.triggered.connect(self.save_file)
|
|
file_menu.addAction(save_action)
|
|
|
|
save_as_action = QAction('Save As...', self)
|
|
save_as_action.setShortcut('Ctrl+Shift+S')
|
|
save_as_action.triggered.connect(self.save_file_as)
|
|
file_menu.addAction(save_as_action)
|
|
|
|
file_menu.addSeparator()
|
|
|
|
backup_action = QAction('Create Backup', self)
|
|
backup_action.triggered.connect(self.create_backup)
|
|
file_menu.addAction(backup_action)
|
|
|
|
file_menu.addSeparator()
|
|
|
|
exit_action = QAction('Exit', self)
|
|
exit_action.setShortcut('Ctrl+Q')
|
|
exit_action.triggered.connect(self.close)
|
|
file_menu.addAction(exit_action)
|
|
|
|
# Edit menu
|
|
edit_menu = menubar.addMenu('Edit')
|
|
|
|
refresh_action = QAction('Refresh Data', self)
|
|
refresh_action.setShortcut('F5')
|
|
refresh_action.triggered.connect(self.refresh_data)
|
|
edit_menu.addAction(refresh_action)
|
|
|
|
# Help menu
|
|
help_menu = menubar.addMenu('Help')
|
|
|
|
about_action = QAction('About', self)
|
|
about_action.triggered.connect(self.show_about)
|
|
help_menu.addAction(about_action)
|
|
|
|
def create_toolbar(self):
|
|
"""Create the toolbar."""
|
|
toolbar = QToolBar()
|
|
self.addToolBar(toolbar)
|
|
|
|
# Open button
|
|
open_btn = QPushButton("Open Save")
|
|
open_btn.clicked.connect(self.open_save_file)
|
|
toolbar.addWidget(open_btn)
|
|
|
|
# Save button
|
|
self.save_btn = QPushButton("Save")
|
|
self.save_btn.clicked.connect(self.save_file)
|
|
self.save_btn.setEnabled(False)
|
|
toolbar.addWidget(self.save_btn)
|
|
|
|
# Backup button
|
|
self.backup_btn = QPushButton("Backup")
|
|
self.backup_btn.clicked.connect(self.create_backup)
|
|
self.backup_btn.setEnabled(False)
|
|
toolbar.addWidget(self.backup_btn)
|
|
|
|
def create_file_info_section(self, parent_layout):
|
|
"""Create the file information section."""
|
|
info_group = QGroupBox("Save File Information")
|
|
info_layout = QGridLayout(info_group)
|
|
|
|
# File path
|
|
info_layout.addWidget(QLabel("File:"), 0, 0)
|
|
self.file_path_label = QLabel("No file loaded")
|
|
self.file_path_label.setStyleSheet("font-weight: bold;")
|
|
info_layout.addWidget(self.file_path_label, 0, 1)
|
|
|
|
# File size
|
|
info_layout.addWidget(QLabel("Size:"), 1, 0)
|
|
self.file_size_label = QLabel("-")
|
|
info_layout.addWidget(self.file_size_label, 1, 1)
|
|
|
|
# Decryption method
|
|
info_layout.addWidget(QLabel("Method:"), 2, 0)
|
|
self.method_label = QLabel("-")
|
|
info_layout.addWidget(self.method_label, 2, 1)
|
|
|
|
# Modified status
|
|
info_layout.addWidget(QLabel("Status:"), 3, 0)
|
|
self.status_label = QLabel("Not modified")
|
|
info_layout.addWidget(self.status_label, 3, 1)
|
|
|
|
parent_layout.addWidget(info_group)
|
|
|
|
def create_main_content(self, parent_layout):
|
|
"""Create the main content area with tabs."""
|
|
self.tab_widget = QTabWidget()
|
|
|
|
# Raw Data tab
|
|
self.create_raw_data_tab()
|
|
|
|
# Vault Info tab
|
|
self.create_vault_info_tab()
|
|
|
|
# Resources tab
|
|
self.create_resources_tab()
|
|
|
|
# Dwellers tab
|
|
self.create_dwellers_tab()
|
|
|
|
# Rooms tab
|
|
self.create_rooms_tab()
|
|
|
|
parent_layout.addWidget(self.tab_widget)
|
|
|
|
def create_raw_data_tab(self):
|
|
"""Create the raw data viewing/editing tab."""
|
|
raw_widget = QWidget()
|
|
layout = QVBoxLayout(raw_widget)
|
|
|
|
# JSON editor
|
|
self.json_editor = QTextEdit()
|
|
self.json_editor.setFont(QFont("Consolas", 10))
|
|
self.json_editor.textChanged.connect(self.on_data_modified)
|
|
layout.addWidget(self.json_editor)
|
|
|
|
# Format button
|
|
format_btn = QPushButton("Format JSON")
|
|
format_btn.clicked.connect(self.format_json)
|
|
layout.addWidget(format_btn)
|
|
|
|
self.tab_widget.addTab(raw_widget, "Raw Data")
|
|
|
|
def create_vault_info_tab(self):
|
|
"""Create the vault information tab."""
|
|
vault_widget = QWidget()
|
|
layout = QVBoxLayout(vault_widget)
|
|
|
|
# Scroll area for vault info
|
|
scroll = QScrollArea()
|
|
scroll_widget = QWidget()
|
|
scroll_layout = QGridLayout(scroll_widget)
|
|
|
|
# Vault basic info
|
|
basic_group = QGroupBox("Basic Information")
|
|
basic_layout = QGridLayout(basic_group)
|
|
|
|
# Vault name
|
|
basic_layout.addWidget(QLabel("Vault Name:"), 0, 0)
|
|
self.vault_name_edit = QLineEdit()
|
|
self.vault_name_edit.textChanged.connect(self.on_data_modified)
|
|
basic_layout.addWidget(self.vault_name_edit, 0, 1)
|
|
|
|
# Vault number
|
|
basic_layout.addWidget(QLabel("Vault Number:"), 1, 0)
|
|
self.vault_number_spin = QSpinBox()
|
|
self.vault_number_spin.setRange(1, 999)
|
|
self.vault_number_spin.valueChanged.connect(self.on_data_modified)
|
|
basic_layout.addWidget(self.vault_number_spin, 1, 1)
|
|
|
|
# Experience
|
|
basic_layout.addWidget(QLabel("Experience:"), 2, 0)
|
|
self.experience_spin = QSpinBox()
|
|
self.experience_spin.setRange(0, 999999999)
|
|
self.experience_spin.valueChanged.connect(self.on_data_modified)
|
|
basic_layout.addWidget(self.experience_spin, 2, 1)
|
|
|
|
scroll_layout.addWidget(basic_group, 0, 0)
|
|
|
|
scroll.setWidget(scroll_widget)
|
|
layout.addWidget(scroll)
|
|
|
|
self.tab_widget.addTab(vault_widget, "Vault Info")
|
|
|
|
def create_resources_tab(self):
|
|
"""Create the resources editing tab."""
|
|
resources_widget = QWidget()
|
|
layout = QVBoxLayout(resources_widget)
|
|
|
|
# Resources group
|
|
resources_group = QGroupBox("Vault Resources")
|
|
resources_layout = QGridLayout(resources_group)
|
|
|
|
# Common resources
|
|
self.resource_spins = {}
|
|
resources = [
|
|
("Caps", "caps", 0, 999999999),
|
|
("Food", "food", 0, 999999),
|
|
("Water", "water", 0, 999999),
|
|
("Power", "power", 0, 999999),
|
|
("Stimpaks", "stimpaks", 0, 999999),
|
|
("RadAway", "radaway", 0, 999999),
|
|
("Nuka Cola Quantum", "nuka_quantum", 0, 999999),
|
|
]
|
|
|
|
for i, (display_name, key, min_val, max_val) in enumerate(resources):
|
|
resources_layout.addWidget(QLabel(f"{display_name}:"), i, 0)
|
|
|
|
spin = QSpinBox()
|
|
spin.setRange(min_val, max_val)
|
|
spin.valueChanged.connect(self.on_data_modified)
|
|
self.resource_spins[key] = spin
|
|
resources_layout.addWidget(spin, i, 1)
|
|
|
|
layout.addWidget(resources_group)
|
|
layout.addStretch()
|
|
|
|
self.tab_widget.addTab(resources_widget, "Resources")
|
|
|
|
def create_dwellers_tab(self):
|
|
"""Create the dwellers management tab."""
|
|
dwellers_widget = QWidget()
|
|
layout = QVBoxLayout(dwellers_widget)
|
|
|
|
# Dwellers table
|
|
self.dwellers_table = QTableWidget()
|
|
self.dwellers_table.setColumnCount(8)
|
|
self.dwellers_table.setHorizontalHeaderLabels([
|
|
"Name", "Level", "Health", "Happiness", "Strength", "Perception", "Endurance", "Charisma"
|
|
])
|
|
|
|
# Make table stretch
|
|
header = self.dwellers_table.horizontalHeader()
|
|
header.setSectionResizeMode(QHeaderView.Stretch)
|
|
|
|
layout.addWidget(self.dwellers_table)
|
|
|
|
# Dwellers controls
|
|
controls_layout = QHBoxLayout()
|
|
|
|
refresh_dwellers_btn = QPushButton("Refresh Dwellers")
|
|
refresh_dwellers_btn.clicked.connect(self.refresh_dwellers)
|
|
controls_layout.addWidget(refresh_dwellers_btn)
|
|
|
|
controls_layout.addStretch()
|
|
layout.addLayout(controls_layout)
|
|
|
|
self.tab_widget.addTab(dwellers_widget, "Dwellers")
|
|
|
|
def create_rooms_tab(self):
|
|
"""Create the rooms management tab."""
|
|
rooms_widget = QWidget()
|
|
layout = QVBoxLayout(rooms_widget)
|
|
|
|
# Rooms tree
|
|
self.rooms_tree = QTreeWidget()
|
|
self.rooms_tree.setHeaderLabels(["Room Type", "Level", "Position", "Status"])
|
|
layout.addWidget(self.rooms_tree)
|
|
|
|
# Rooms controls
|
|
controls_layout = QHBoxLayout()
|
|
|
|
refresh_rooms_btn = QPushButton("Refresh Rooms")
|
|
refresh_rooms_btn.clicked.connect(self.refresh_rooms)
|
|
controls_layout.addWidget(refresh_rooms_btn)
|
|
|
|
controls_layout.addStretch()
|
|
layout.addLayout(controls_layout)
|
|
|
|
self.tab_widget.addTab(rooms_widget, "Rooms")
|
|
|
|
def setup_connections(self):
|
|
"""Set up signal connections."""
|
|
pass
|
|
|
|
def open_save_file(self):
|
|
"""Open a save file."""
|
|
filepath, _ = QFileDialog.getOpenFileName(
|
|
self,
|
|
"Open Fallout Shelter Save File",
|
|
"",
|
|
"Save Files (*.sav);;All Files (*)"
|
|
)
|
|
|
|
if filepath:
|
|
self.load_save_file(filepath)
|
|
|
|
def load_save_file(self, filepath: str):
|
|
"""Load a save file using worker thread."""
|
|
self.progress_bar.setVisible(True)
|
|
self.progress_bar.setValue(0)
|
|
|
|
# Create and start worker thread
|
|
self.worker = SaveDecryptionWorker(filepath)
|
|
self.worker.progress_updated.connect(self.progress_bar.setValue)
|
|
self.worker.status_updated.connect(self.status_bar.showMessage)
|
|
self.worker.finished_signal.connect(self.on_save_loaded)
|
|
self.worker.error_signal.connect(self.on_load_error)
|
|
self.worker.start()
|
|
|
|
def on_save_loaded(self, result: Dict):
|
|
"""Handle successful save file loading."""
|
|
self.save_data = result["data"]
|
|
self.save_filepath = self.worker.filepath
|
|
self.save_method = result["method"]
|
|
self.is_modified = False
|
|
|
|
# Update UI
|
|
self.update_file_info()
|
|
self.populate_data()
|
|
self.update_ui_state()
|
|
|
|
self.progress_bar.setVisible(False)
|
|
self.status_bar.showMessage("Save file loaded successfully", 3000)
|
|
|
|
def on_load_error(self, error_msg: str):
|
|
"""Handle save file loading error."""
|
|
self.progress_bar.setVisible(False)
|
|
self.status_bar.showMessage("Failed to load save file", 3000)
|
|
|
|
QMessageBox.critical(self, "Error", f"Failed to load save file:\n{error_msg}")
|
|
|
|
def update_file_info(self):
|
|
"""Update the file information display."""
|
|
if self.save_filepath:
|
|
filename = os.path.basename(self.save_filepath)
|
|
self.file_path_label.setText(filename)
|
|
|
|
file_size = os.path.getsize(self.save_filepath)
|
|
self.file_size_label.setText(f"{file_size:,} bytes")
|
|
|
|
self.method_label.setText(self.save_method or "Unknown")
|
|
|
|
self.update_status_label()
|
|
|
|
def update_status_label(self):
|
|
"""Update the modification status label."""
|
|
if self.is_modified:
|
|
self.status_label.setText("Modified")
|
|
self.status_label.setStyleSheet("color: orange; font-weight: bold;")
|
|
else:
|
|
self.status_label.setText("Not modified")
|
|
self.status_label.setStyleSheet("color: green;")
|
|
|
|
def populate_data(self):
|
|
"""Populate all tabs with save data."""
|
|
if not self.save_data:
|
|
return
|
|
|
|
# Raw data tab
|
|
json_text = json.dumps(self.save_data, indent=2)
|
|
self.json_editor.setPlainText(json_text)
|
|
|
|
# Vault info tab
|
|
self.populate_vault_info()
|
|
|
|
# Resources tab
|
|
self.populate_resources()
|
|
|
|
# Dwellers tab
|
|
self.refresh_dwellers()
|
|
|
|
# Rooms tab
|
|
self.refresh_rooms()
|
|
|
|
def populate_vault_info(self):
|
|
"""Populate vault information."""
|
|
if not self.save_data:
|
|
return
|
|
|
|
# Try to find vault info in common locations
|
|
vault_info = self.save_data
|
|
|
|
# Vault name
|
|
name_fields = ["vaultName", "name", "VaultName"]
|
|
for field in name_fields:
|
|
if field in vault_info:
|
|
self.vault_name_edit.setText(str(vault_info[field]))
|
|
break
|
|
|
|
# Vault number
|
|
number_fields = ["vaultNumber", "number", "VaultNumber"]
|
|
for field in number_fields:
|
|
if field in vault_info:
|
|
self.vault_number_spin.setValue(int(vault_info[field]))
|
|
break
|
|
|
|
# Experience
|
|
exp_fields = ["experience", "exp", "Experience"]
|
|
for field in exp_fields:
|
|
if field in vault_info:
|
|
self.experience_spin.setValue(int(vault_info[field]))
|
|
break
|
|
|
|
def populate_resources(self):
|
|
"""Populate resource information."""
|
|
if not self.save_data:
|
|
return
|
|
|
|
# Try to find resources in common locations
|
|
resources_data = self.save_data.get("resources", self.save_data)
|
|
|
|
# Map of UI keys to possible data keys
|
|
resource_mappings = {
|
|
"caps": ["caps", "money", "Caps"],
|
|
"food": ["food", "Food"],
|
|
"water": ["water", "Water"],
|
|
"power": ["power", "electricity", "Power"],
|
|
"stimpaks": ["stimpaks", "stimpak", "Stimpaks"],
|
|
"radaway": ["radaway", "RadAway", "radAway"],
|
|
"nuka_quantum": ["nuka_quantum", "quantum", "NukaQuantum"],
|
|
}
|
|
|
|
for ui_key, data_keys in resource_mappings.items():
|
|
if ui_key in self.resource_spins:
|
|
for data_key in data_keys:
|
|
if data_key in resources_data:
|
|
self.resource_spins[ui_key].setValue(int(resources_data[data_key]))
|
|
break
|
|
|
|
def refresh_dwellers(self):
|
|
"""Refresh the dwellers table."""
|
|
if not self.save_data:
|
|
return
|
|
|
|
# Try to find dwellers data
|
|
dwellers_data = self.save_data.get("dwellers", [])
|
|
if not isinstance(dwellers_data, list):
|
|
dwellers_data = []
|
|
|
|
self.dwellers_table.setRowCount(len(dwellers_data))
|
|
|
|
for i, dweller in enumerate(dwellers_data):
|
|
if isinstance(dweller, dict):
|
|
# Name
|
|
name = dweller.get("name", f"Dweller {i+1}")
|
|
self.dwellers_table.setItem(i, 0, QTableWidgetItem(str(name)))
|
|
|
|
# Level
|
|
level = dweller.get("level", 1)
|
|
self.dwellers_table.setItem(i, 1, QTableWidgetItem(str(level)))
|
|
|
|
# Health
|
|
health = dweller.get("health", 100)
|
|
self.dwellers_table.setItem(i, 2, QTableWidgetItem(str(health)))
|
|
|
|
# Happiness
|
|
happiness = dweller.get("happiness", 100)
|
|
self.dwellers_table.setItem(i, 3, QTableWidgetItem(str(happiness)))
|
|
|
|
# SPECIAL stats
|
|
special = dweller.get("special", {})
|
|
stats = ["strength", "perception", "endurance", "charisma"]
|
|
for j, stat in enumerate(stats):
|
|
value = special.get(stat, 1)
|
|
self.dwellers_table.setItem(i, 4+j, QTableWidgetItem(str(value)))
|
|
|
|
def refresh_rooms(self):
|
|
"""Refresh the rooms tree."""
|
|
self.rooms_tree.clear()
|
|
|
|
if not self.save_data:
|
|
return
|
|
|
|
# Try to find rooms data
|
|
rooms_data = self.save_data.get("rooms", [])
|
|
if not isinstance(rooms_data, list):
|
|
rooms_data = []
|
|
|
|
for i, room in enumerate(rooms_data):
|
|
if isinstance(room, dict):
|
|
room_type = room.get("type", "Unknown")
|
|
level = room.get("level", 1)
|
|
position = f"({room.get('x', 0)}, {room.get('y', 0)})"
|
|
status = room.get("status", "Active")
|
|
|
|
item = QTreeWidgetItem([room_type, str(level), position, status])
|
|
self.rooms_tree.addTopLevelItem(item)
|
|
|
|
def on_data_modified(self):
|
|
"""Handle data modification."""
|
|
if not self.is_modified:
|
|
self.is_modified = True
|
|
self.update_status_label()
|
|
self.update_ui_state()
|
|
|
|
def format_json(self):
|
|
"""Format the JSON in the raw data tab."""
|
|
try:
|
|
text = self.json_editor.toPlainText()
|
|
data = json.loads(text)
|
|
formatted = json.dumps(data, indent=2)
|
|
self.json_editor.setPlainText(formatted)
|
|
except json.JSONDecodeError as e:
|
|
QMessageBox.warning(self, "JSON Error", f"Invalid JSON: {e}")
|
|
|
|
def save_file(self):
|
|
"""Save the current file."""
|
|
if not self.save_filepath:
|
|
self.save_file_as()
|
|
return
|
|
|
|
if self.write_save_file(self.save_filepath):
|
|
self.is_modified = False
|
|
self.update_status_label()
|
|
self.update_ui_state()
|
|
self.status_bar.showMessage("File saved successfully", 3000)
|
|
else:
|
|
QMessageBox.critical(self, "Error", "Failed to save file")
|
|
|
|
def save_file_as(self):
|
|
"""Save as a new file."""
|
|
filepath, _ = QFileDialog.getSaveFileName(
|
|
self,
|
|
"Save Fallout Shelter Save File",
|
|
"",
|
|
"Save Files (*.sav);;All Files (*)"
|
|
)
|
|
|
|
if filepath:
|
|
if self.write_save_file(filepath):
|
|
self.save_filepath = filepath
|
|
self.is_modified = False
|
|
self.update_file_info()
|
|
self.update_ui_state()
|
|
self.status_bar.showMessage("File saved successfully", 3000)
|
|
else:
|
|
QMessageBox.critical(self, "Error", "Failed to save file")
|
|
|
|
def write_save_file(self, filepath: str) -> 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() |