Files
Fallout-Shelter-Save-Editor/fallout_shelter_editor.py
2025-08-05 04:54:50 +00:00

605 lines
23 KiB
Python

#!/usr/bin/env python3
"""
Fallout Shelter Save Editor
A comprehensive tool for editing Fallout Shelter save files.
"""
import base64
import json
import zlib
import gzip
import struct
import os
import shutil
from datetime import datetime
from typing import Dict, Any, Optional, List
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
class FalloutShelterSave:
"""Handles Fallout Shelter save file operations."""
# Fallout Shelter encryption constants (from JavaScript code)
AES_KEY = bytes([
(2815074099 >> 24) & 0xFF, (2815074099 >> 16) & 0xFF, (2815074099 >> 8) & 0xFF, 2815074099 & 0xFF,
(1725469378 >> 24) & 0xFF, (1725469378 >> 16) & 0xFF, (1725469378 >> 8) & 0xFF, 1725469378 & 0xFF,
(4039046167 >> 24) & 0xFF, (4039046167 >> 16) & 0xFF, (4039046167 >> 8) & 0xFF, 4039046167 & 0xFF,
(874293617 >> 24) & 0xFF, (874293617 >> 16) & 0xFF, (874293617 >> 8) & 0xFF, 874293617 & 0xFF,
(3063605751 >> 24) & 0xFF, (3063605751 >> 16) & 0xFF, (3063605751 >> 8) & 0xFF, 3063605751 & 0xFF,
(3133984764 >> 24) & 0xFF, (3133984764 >> 16) & 0xFF, (3133984764 >> 8) & 0xFF, 3133984764 & 0xFF,
(4097598161 >> 24) & 0xFF, (4097598161 >> 16) & 0xFF, (4097598161 >> 8) & 0xFF, 4097598161 & 0xFF,
(3620741625 >> 24) & 0xFF, (3620741625 >> 16) & 0xFF, (3620741625 >> 8) & 0xFF, 3620741625 & 0xFF
])
AES_IV = bytes.fromhex("7475383967656A693334307438397532")
def __init__(self, filepath: str = None):
self.filepath = filepath
self.raw_data = None
self.decoded_data = None
self.save_data = None
self.is_loaded = False
def load_save(self, filepath: str = None) -> bool:
"""Load and decode a Fallout Shelter save file."""
if filepath:
self.filepath = filepath
if not self.filepath or not os.path.exists(self.filepath):
return False
try:
with open(self.filepath, 'rb') as f:
self.raw_data = f.read()
# Decode base64
self.decoded_data = base64.b64decode(self.raw_data)
# Try to decompress and parse
self.save_data = self._parse_save_data()
self.is_loaded = True
return True
except Exception as e:
print(f"Error loading save: {e}")
return False
def _parse_save_data(self) -> Dict[str, Any]:
"""Parse the decoded save data using AES-CBC decryption."""
try:
# Decrypt using AES-CBC
cipher = AES.new(self.AES_KEY, AES.MODE_CBC, self.AES_IV)
decrypted_data = cipher.decrypt(self.decoded_data)
# Remove PKCS7 padding
try:
unpadded_data = unpad(decrypted_data, AES.block_size)
except ValueError:
# If unpadding fails, try without unpadding (some implementations don't pad)
unpadded_data = decrypted_data.rstrip(b'\x00')
# Convert to string and parse JSON
json_str = unpadded_data.decode('utf-8')
return json.loads(json_str)
except Exception as e:
print(f"AES decryption failed: {e}")
# Fallback to old methods if AES fails
return self._try_fallback_methods()
def _try_fallback_methods(self) -> Dict[str, Any]:
"""Fallback methods if AES decryption fails."""
decompression_methods = [
self._try_raw_json,
self._try_zlib_decompress,
self._try_gzip_decompress,
self._try_custom_decompress,
]
for method in decompression_methods:
try:
result = method(self.decoded_data)
if result:
return result
except Exception as e:
continue
# If all methods fail, return raw data info
return {
"error": "Could not parse save data",
"raw_size": len(self.decoded_data),
"raw_preview": self.decoded_data[:100].hex()
}
def _try_raw_json(self, data: bytes) -> Optional[Dict]:
"""Try to parse as raw JSON."""
try:
text = data.decode('utf-8')
return json.loads(text)
except:
return None
def _try_zlib_decompress(self, data: bytes) -> Optional[Dict]:
"""Try zlib decompression then JSON parse."""
try:
decompressed = zlib.decompress(data)
text = decompressed.decode('utf-8')
return json.loads(text)
except:
return None
def _try_gzip_decompress(self, data: bytes) -> Optional[Dict]:
"""Try gzip decompression then JSON parse."""
try:
decompressed = gzip.decompress(data)
text = decompressed.decode('utf-8')
return json.loads(text)
except:
return None
def _try_custom_decompress(self, data: bytes) -> Optional[Dict]:
"""Try custom Fallout Shelter decompression."""
# Some Fallout Shelter saves use a custom format
# This is a placeholder for reverse-engineered decompression
try:
# Check for common patterns in Fallout Shelter saves
if len(data) > 4:
# Try reading as little-endian uint32 header
header = struct.unpack('<I', data[:4])[0]
if header < len(data): # Reasonable size check
# Try decompressing the rest
compressed_data = data[4:]
decompressed = zlib.decompress(compressed_data)
text = decompressed.decode('utf-8')
return json.loads(text)
except:
pass
return None
def backup_save(self) -> str:
"""Create a backup of the current save file."""
if not self.filepath:
return ""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{self.filepath}.backup_{timestamp}"
shutil.copy2(self.filepath, backup_path)
return backup_path
def save_file(self, filepath: str = None) -> bool:
"""Save the modified data back to file."""
if not self.save_data or not self.is_loaded:
return False
save_path = filepath or self.filepath
if not save_path:
return False
try:
# Convert back to JSON (compact format)
json_data = json.dumps(self.save_data, separators=(',', ':'))
json_bytes = json_data.encode('utf-8')
# Pad data for AES encryption
padded_data = pad(json_bytes, AES.block_size)
# Encrypt using AES-CBC
cipher = AES.new(self.AES_KEY, AES.MODE_CBC, self.AES_IV)
encrypted_data = cipher.encrypt(padded_data)
# Encode to base64
encoded = base64.b64encode(encrypted_data)
# Write to file
with open(save_path, 'wb') as f:
f.write(encoded)
return True
except Exception as e:
print(f"Error saving file: {e}")
return False
class SaveEditorGUI:
"""GUI for the Fallout Shelter Save Editor."""
def __init__(self):
self.root = tk.Tk()
self.root.title("Fallout Shelter Save Editor")
self.root.geometry("800x600")
self.save_handler = FalloutShelterSave()
self.setup_ui()
def setup_ui(self):
"""Set up the user interface."""
# Menu bar
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Open Save", command=self.open_save)
file_menu.add_command(label="Save", command=self.save_file)
file_menu.add_command(label="Save As", command=self.save_as)
file_menu.add_separator()
file_menu.add_command(label="Create Backup", command=self.create_backup)
file_menu.add_separator()
file_menu.add_command(label="Exit", command=self.root.quit)
# Main frame
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# File info frame
info_frame = ttk.LabelFrame(main_frame, text="Save File Info", padding="5")
info_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
self.file_label = ttk.Label(info_frame, text="No file loaded")
self.file_label.grid(row=0, column=0, sticky=tk.W)
# Notebook for different edit categories
self.notebook = ttk.Notebook(main_frame)
self.notebook.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S))
# Raw data tab
self.setup_raw_tab()
# Vault stats tab (placeholder for when we can parse the data)
self.setup_vault_tab()
# Resources tab
self.setup_resources_tab()
# Dwellers tab
self.setup_dwellers_tab()
# Configure grid weights
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
main_frame.columnconfigure(0, weight=1)
main_frame.rowconfigure(1, weight=1)
def setup_raw_tab(self):
"""Set up the raw data viewing tab."""
raw_frame = ttk.Frame(self.notebook)
self.notebook.add(raw_frame, text="Raw Data")
# Text area for raw data
self.raw_text = scrolledtext.ScrolledText(raw_frame, wrap=tk.WORD, height=20)
self.raw_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
def setup_vault_tab(self):
"""Set up the vault statistics tab."""
vault_frame = ttk.Frame(self.notebook)
self.notebook.add(vault_frame, text="Vault Stats")
# Vault info
ttk.Label(vault_frame, text="Vault Name:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
self.vault_name = ttk.Entry(vault_frame, width=30)
self.vault_name.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2)
ttk.Label(vault_frame, text="Vault Mode:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2)
self.vault_number = ttk.Entry(vault_frame, width=30)
self.vault_number.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
def setup_resources_tab(self):
"""Set up the resources editing tab."""
resources_frame = ttk.Frame(self.notebook)
self.notebook.add(resources_frame, text="Resources")
# Common resources
resources = ["Caps", "Food", "Water", "Power", "Stimpaks", "RadAway", "Nuka Cola Quantum"]
self.resource_entries = {}
for i, resource in enumerate(resources):
ttk.Label(resources_frame, text=f"{resource}:").grid(row=i, column=0, sticky=tk.W, padx=5, pady=2)
entry = ttk.Entry(resources_frame, width=20)
entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=2)
self.resource_entries[resource.lower().replace(" ", "_")] = entry
# Add buttons for resource modification
button_frame = ttk.Frame(resources_frame)
button_frame.grid(row=len(resources), column=0, columnspan=2, pady=10)
ttk.Button(button_frame, text="Apply Changes", command=self.apply_resource_changes).pack(side=tk.LEFT, padx=5)
ttk.Button(button_frame, text="Max All Resources", command=self.max_all_resources).pack(side=tk.LEFT, padx=5)
def setup_dwellers_tab(self):
"""Set up the dwellers editing tab."""
dwellers_frame = ttk.Frame(self.notebook)
self.notebook.add(dwellers_frame, text="Dwellers")
# Dwellers list (placeholder)
ttk.Label(dwellers_frame, text="Dwellers management will be available when save format is decoded").pack(pady=20)
def open_save(self):
"""Open a save file."""
filepath = filedialog.askopenfilename(
title="Select Fallout Shelter Save File",
filetypes=[("Save files", "*.sav"), ("All files", "*.*")]
)
if filepath:
if self.save_handler.load_save(filepath):
self.file_label.config(text=f"Loaded: {os.path.basename(filepath)}")
self.update_display()
messagebox.showinfo("Success", "Save file loaded successfully!")
else:
messagebox.showerror("Error", "Failed to load save file")
def update_display(self):
"""Update the display with loaded save data."""
if not self.save_handler.is_loaded:
return
# Update raw data tab
if self.save_handler.save_data:
raw_text = json.dumps(self.save_handler.save_data, indent=2)
self.raw_text.delete(1.0, tk.END)
self.raw_text.insert(1.0, raw_text)
# Try to populate other tabs if data structure is known
self.populate_vault_info()
self.populate_resources()
def populate_vault_info(self):
"""Populate vault information if available."""
if not self.save_handler.save_data:
return
data = self.save_handler.save_data
# Get vault information from the correct location
if "vault" in data and isinstance(data["vault"], dict):
vault = data["vault"]
# Vault name
if "VaultName" in vault:
self.vault_name.delete(0, tk.END)
self.vault_name.insert(0, str(vault["VaultName"]))
# Vault mode (instead of number, Fallout Shelter has mode)
if "VaultMode" in vault:
self.vault_number.delete(0, tk.END)
self.vault_number.insert(0, str(vault["VaultMode"]))
def populate_resources(self):
"""Populate resource information if available."""
if not self.save_handler.save_data:
return
data = self.save_handler.save_data
# Fallout Shelter resource field mappings (based on actual save structure)
resource_mappings = {
"caps": ["Nuka"], # Caps are stored as "Nuka" in the save
"food": ["Food"],
"water": ["Water"],
"power": ["Energy"], # Power is stored as "Energy"
"stimpaks": ["StimPack"],
"radaway": ["RadAway"],
"nuka_cola_quantum": ["NukaColaQuantum"]
}
# Try to find and populate resource values
# Look specifically in vault.storage.resources
vault_storage = self._get_vault_storage(data)
if vault_storage and "resources" in vault_storage:
resources = vault_storage["resources"]
for resource_key, entry in self.resource_entries.items():
possible_fields = resource_mappings.get(resource_key, [resource_key])
for field in possible_fields:
if field in resources:
entry.delete(0, tk.END)
entry.insert(0, str(int(resources[field])))
break
def _find_nested_value(self, data, key):
"""Recursively search for a key in nested dictionaries."""
if isinstance(data, dict):
if key in data:
return data[key]
for value in data.values():
result = self._find_nested_value(value, key)
if result is not None:
return result
elif isinstance(data, list):
for item in data:
result = self._find_nested_value(item, key)
if result is not None:
return result
return None
def save_file(self):
"""Save the current file."""
if self.save_handler.save_file():
messagebox.showinfo("Success", "Save file updated successfully!")
else:
messagebox.showerror("Error", "Failed to save file")
def save_as(self):
"""Save as a new file."""
filepath = filedialog.asksaveasfilename(
title="Save As",
defaultextension=".sav",
filetypes=[("Save files", "*.sav"), ("All files", "*.*")]
)
if filepath:
if self.save_handler.save_file(filepath):
messagebox.showinfo("Success", f"Save file created: {os.path.basename(filepath)}")
else:
messagebox.showerror("Error", "Failed to create save file")
def create_backup(self):
"""Create a backup of the current save."""
if not self.save_handler.filepath:
messagebox.showwarning("Warning", "No save file loaded")
return
backup_path = self.save_handler.backup_save()
if backup_path:
messagebox.showinfo("Success", f"Backup created: {os.path.basename(backup_path)}")
else:
messagebox.showerror("Error", "Failed to create backup")
def apply_resource_changes(self):
"""Apply resource changes to the save data."""
if not self.save_handler.save_data:
messagebox.showwarning("Warning", "No save data loaded")
return
try:
# Fallout Shelter resource field mappings
resource_mappings = {
"caps": ["Nuka"],
"food": ["Food"],
"water": ["Water"],
"power": ["Energy"],
"stimpaks": ["StimPack"],
"radaway": ["RadAway"],
"nuka_cola_quantum": ["NukaColaQuantum"]
}
# Get vault storage
vault_storage = self._get_vault_storage(self.save_handler.save_data)
if not vault_storage or "resources" not in vault_storage:
messagebox.showerror("Error", "Could not find vault storage in save data")
return
resources = vault_storage["resources"]
changes_made = 0
for resource_key, entry in self.resource_entries.items():
value_str = entry.get().strip()
if value_str:
try:
value = float(value_str) # Fallout Shelter uses floats for resources
possible_fields = resource_mappings.get(resource_key, [resource_key])
for field in possible_fields:
if field in resources:
resources[field] = value
changes_made += 1
break
except ValueError:
messagebox.showerror("Error", f"Invalid value for {resource_key}: {value_str}")
return
if changes_made > 0:
messagebox.showinfo("Success", f"Applied {changes_made} resource changes")
else:
messagebox.showinfo("Info", "No changes were applied")
except Exception as e:
messagebox.showerror("Error", f"Failed to apply changes: {e}")
def max_all_resources(self):
"""Set all resources to maximum values."""
max_values = {
"caps": 999999,
"food": 999999,
"water": 999999,
"power": 999999,
"stimpaks": 999,
"radaway": 999,
"nuka_cola_quantum": 999
}
for resource_key, entry in self.resource_entries.items():
if resource_key in max_values:
entry.delete(0, tk.END)
entry.insert(0, str(max_values[resource_key]))
self.apply_resource_changes()
def _get_vault_storage(self, data):
"""Get the vault storage section from save data."""
if isinstance(data, dict) and "vault" in data:
vault = data["vault"]
if isinstance(vault, dict) and "storage" in vault:
return vault["storage"]
return None
def _set_nested_value(self, data, key, value):
"""Recursively search for a key and set its value."""
if isinstance(data, dict):
if key in data:
data[key] = value
return True
for nested_data in data.values():
if self._set_nested_value(nested_data, key, value):
return True
elif isinstance(data, list):
for item in data:
if self._set_nested_value(item, key, value):
return True
return False
def run(self):
"""Start the GUI."""
self.root.mainloop()
def main():
"""Main function."""
print("Fallout Shelter Save Editor")
print("=" * 30)
# Check if GUI is available
try:
app = SaveEditorGUI()
app.run()
except ImportError:
print("GUI not available, running in console mode")
console_mode()
def console_mode():
"""Console-based interface."""
print("\nConsole Mode")
print("1. Analyze save file")
print("2. Create backup")
print("3. Exit")
while True:
choice = input("\nEnter choice (1-3): ").strip()
if choice == "1":
filepath = input("Enter save file path: ").strip()
if os.path.exists(filepath):
save = FalloutShelterSave(filepath)
if save.load_save():
print(f"Save loaded successfully!")
print(f"Data preview: {str(save.save_data)[:200]}...")
else:
print("Failed to load save file")
else:
print("File not found")
elif choice == "2":
filepath = input("Enter save file path: ").strip()
if os.path.exists(filepath):
save = FalloutShelterSave(filepath)
backup_path = save.backup_save()
if backup_path:
print(f"Backup created: {backup_path}")
else:
print("Failed to create backup")
else:
print("File not found")
elif choice == "3":
break
else:
print("Invalid choice")
if __name__ == "__main__":
main()