Forked from GiowGiow/digimon_cyber_sleuth_switch_to_pc_save_converter.py
Last active
October 24, 2025 17:26
-
-
Save joaociocca/1d6442c329616b260a0490ed4efc6502 to your computer and use it in GitHub Desktop.
Automatically converts Digimon Story Cyber Sleuth save files from Nintendo Switch format to PC format, making them compatible with the Steam/PC version of the game
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Digimon Story Cyber Sleuth Save Converter | |
| ========================================== | |
| Automatically converts Digimon Story Cyber Sleuth save files from Nintendo Switch | |
| format to PC format, making them compatible with the Steam/PC version of the game. | |
| Requirements: | |
| ------------- | |
| • Python 3.7 or higher | |
| • Internet connection (for initial DSCSTools download) | |
| • Switch save files: 000X.bin and slot_000X.bin | |
| Usage Examples: | |
| --------------- | |
| # Convert all save files in current directory (recommended) | |
| python digimon_cyber_sleuth_switch_to_pc_save_converter.py | |
| # Convert all saves in a specific directory | |
| python digimon_cyber_sleuth_switch_to_pc_save_converter.py --input ./my_saves | |
| # Convert specific save files | |
| python digimon_cyber_sleuth_switch_to_pc_save_converter.py --main 0000.bin --slot slot_0000.bin | |
| # Enable verbose logging for troubleshooting | |
| python digimon_cyber_sleuth_switch_to_pc_save_converter.py --verbose | |
| # Custom output directory | |
| python digimon_cyber_sleuth_switch_to_pc_save_converter.py --output ./pc_saves | |
| How It Works: | |
| ------------- | |
| 1. Automatically downloads DSCSToolsCLI from GitHub (first run only) | |
| 2. Copies original files to 'modified/' folder for processing | |
| 3. Removes specific byte sequences from main save files | |
| 4. Adjusts version strings and padding in slot save files | |
| 5. Encrypts processed files and saves to 'output/' folder | |
| 6. Original files remain completely untouched | |
| Output Structure: | |
| ----------------- | |
| your_saves/ | |
| ├── 0000.bin (original - untouched) | |
| ├── slot_0000.bin (original - untouched) | |
| ├── tools/ | |
| │ └── DSCSToolsCLI.exe (auto-downloaded) | |
| ├── modified/ | |
| │ ├── 0000.bin (processed copies) | |
| │ └── slot_0000.bin (processed copies) | |
| └── output/ | |
| ├── 0000.bin (final PC-compatible files) | |
| └── slot_0000.bin (ready to use with PC game) | |
| Platform Support: | |
| ----------------- | |
| • Windows: Fully tested and supported | |
| • Linux: Tested, needs Wine for file encryption, works perfectly | |
| • macOS: Should work but untested | |
| For help: python digimon_cyber_sleuth_switch_to_pc_save_converter.py --help | |
| """ | |
| import sys | |
| import shutil | |
| import subprocess | |
| import logging | |
| import platform | |
| import urllib.request | |
| import zipfile | |
| import tarfile | |
| import argparse | |
| from pathlib import Path | |
| class DSCSSaveConverter: | |
| def __init__(self, dscs_tools_path=None): | |
| self.logger = logging.getLogger(__name__) | |
| # Auto-detect or download DSCSTools | |
| if dscs_tools_path is None: | |
| self.dscs_tools_path = self.get_dscs_tools_path() | |
| else: | |
| self.dscs_tools_path = dscs_tools_path | |
| # Offsets to remove 4-byte values from 000X.bin (before removal) | |
| self.main_save_offsets = [ | |
| 0x0004BA0C, | |
| 0x0004BA9C, | |
| 0x0004BACC, | |
| 0x0004BB0C, | |
| 0x000AD1AC, | |
| 0x000AD23C, | |
| 0x000AD26C, | |
| 0x000AD2AC, | |
| ] | |
| # Target pattern and replacement for slot file | |
| self.slot_target_hex = "843600".encode("ascii") # Convert string to bytes | |
| self.slot_replacement_hex = "843568".encode("ascii") # Convert string to bytes | |
| self.slot_pattern_start = "19, 84".encode("ascii") # Version info start pattern | |
| def get_dscs_tools_path(self): | |
| """Get the path to DSCSToolsCLI, downloading if necessary.""" | |
| tools_dir = Path("tools") | |
| # Check for existing tools | |
| if platform.system() == "Windows": | |
| dscs_cli_path = tools_dir / "DSCSToolsCLI.exe" | |
| else: | |
| dscs_cli_path = tools_dir / "DSCSToolsCLI" | |
| if dscs_cli_path.exists(): | |
| self.logger.info(f"Found existing DSCSTools at: {dscs_cli_path}") | |
| return str(dscs_cli_path) | |
| # Download and extract tools if not found | |
| self.logger.info("DSCSTools not found. Downloading...") | |
| return self.download_dscs_tools() | |
| def download_dscs_tools(self): | |
| """Download and extract DSCSTools from GitHub releases.""" | |
| tools_dir = Path("tools") | |
| tools_dir.mkdir(exist_ok=True) | |
| # Always download Windows version for Wine compatibility | |
| filename = "DSCSTools_1.0.0_win64-static.zip" | |
| dscs_cli_name = "DSCSToolsCLI.exe" | |
| base_url = "https://github.com/SydMontague/DSCSTools/releases/download/v1.0.0/" | |
| download_url = base_url + filename | |
| archive_path = tools_dir / filename | |
| try: | |
| self.logger.info(f"Downloading {filename} for Wine...") | |
| urllib.request.urlretrieve(download_url, archive_path) | |
| self.logger.info(f"Downloaded to: {archive_path}") | |
| self.logger.info(f"Extracting {filename}...") | |
| with zipfile.ZipFile(archive_path, "r") as zip_ref: | |
| zip_ref.extractall(tools_dir) | |
| # Remove the archive after extraction | |
| archive_path.unlink() | |
| self.logger.info("Extraction complete, archive removed") | |
| # Find the extracted DSCSToolsCLI.exe | |
| dscs_cli_path = tools_dir / dscs_cli_name | |
| if not dscs_cli_path.exists(): | |
| # Search for it in subdirectories | |
| for file_path in tools_dir.rglob(dscs_cli_name): | |
| dscs_cli_path = file_path | |
| break | |
| if dscs_cli_path.exists(): | |
| self.logger.info(f"DSCSTools (Windows version) ready at: {dscs_cli_path}") | |
| self.logger.info("Will use Wine to run it on Linux") | |
| return str(dscs_cli_path) | |
| else: | |
| raise FileNotFoundError(f"Could not find {dscs_cli_name} after extraction") | |
| except Exception as e: | |
| self.logger.error(f"Failed to download DSCSTools: {e}") | |
| raise RuntimeError(f"Could not download or extract DSCSTools: {e}") | |
| def validate_files(self, main_save_path, slot_save_path): | |
| """Validate that the required files exist.""" | |
| if not Path(main_save_path).exists(): | |
| raise FileNotFoundError(f"Main save file not found: {main_save_path}") | |
| if not Path(slot_save_path).exists(): | |
| raise FileNotFoundError(f"Slot save file not found: {slot_save_path}") | |
| if not Path(self.dscs_tools_path).exists(): | |
| raise FileNotFoundError(f"DSCSToolsCLI not found: {self.dscs_tools_path}") | |
| def copy_files_for_processing( | |
| self, main_save_path, slot_save_path, modified_dir=None | |
| ): | |
| """Copy original files to a modified folder for processing.""" | |
| if modified_dir is None: | |
| modified_dir = Path(main_save_path).parent / "modified" | |
| modified_dir = Path(modified_dir) | |
| modified_dir.mkdir(exist_ok=True) | |
| main_save_path = Path(main_save_path) | |
| slot_save_path = Path(slot_save_path) | |
| main_modified = modified_dir / main_save_path.name | |
| slot_modified = modified_dir / slot_save_path.name | |
| self.logger.info(f"Copying to modified folder: {main_modified}") | |
| shutil.copy2(main_save_path, main_modified) | |
| self.logger.info(f"Copying to modified folder: {slot_modified}") | |
| shutil.copy2(slot_save_path, slot_modified) | |
| return str(main_modified), str(slot_modified) | |
| def process_main_save(self, file_path): | |
| """Remove 8 4-byte values from the main save file at specified offsets.""" | |
| self.logger.info(f"Processing main save file: {file_path}") | |
| with open(file_path, "rb") as f: | |
| data = bytearray(f.read()) | |
| # Sort offsets in descending order to maintain correct positions during removal | |
| sorted_offsets = sorted(self.main_save_offsets, reverse=True) | |
| for offset in sorted_offsets: | |
| if offset + 4 <= len(data): | |
| self.logger.debug(f"Removing 4 bytes at offset 0x{offset:08X}") | |
| del data[offset : offset + 4] | |
| else: | |
| self.logger.warning(f"Offset 0x{offset:08X} is beyond file size") | |
| with open(file_path, "wb") as f: | |
| f.write(data) | |
| self.logger.info( | |
| f"Main save processing complete. Removed {len(self.main_save_offsets)} 4-byte segments." | |
| ) | |
| def process_slot_save(self, file_path): | |
| """Process slot save file: pad to 0x100 and change version string.""" | |
| self.logger.info(f"Processing slot save file: {file_path}") | |
| with open(file_path, "rb") as f: | |
| data = bytearray(f.read()) | |
| # Find the pattern "19, 84" (start of version info) | |
| pattern_pos = data.find(self.slot_pattern_start) | |
| if pattern_pos == -1: | |
| raise ValueError("Could not find version pattern in slot save file") | |
| self.logger.debug(f"Found version pattern at offset 0x{pattern_pos:08X}") | |
| # Calculate how many null bytes to add before the pattern to reach 0x100 | |
| target_offset = 0x100 | |
| if pattern_pos < target_offset: | |
| bytes_to_add = target_offset - pattern_pos | |
| self.logger.debug(f"Adding {bytes_to_add} null bytes to reach offset 0x100") | |
| data = bytearray(b"\x00" * bytes_to_add) + data | |
| # Find and replace the version string | |
| old_version_pos = data.find(self.slot_target_hex) | |
| if old_version_pos != -1: | |
| self.logger.info( | |
| f"Changing version from 843600 to 843568 at offset 0x{old_version_pos:08X}" | |
| ) | |
| data[old_version_pos : old_version_pos + len(self.slot_target_hex)] = ( | |
| self.slot_replacement_hex | |
| ) | |
| else: | |
| self.logger.warning("Could not find version string to replace") | |
| with open(file_path, "wb") as f: | |
| f.write(data) | |
| self.logger.info("Slot save processing complete.") | |
| def encrypt_save(self, source_path, target_path): | |
| """Use DSCSToolsCLI (via Wine if needed) to encrypt the save file.""" | |
| self.logger.info(f"Encrypting: {source_path} -> {target_path}") | |
| # Check if we need to use Wine (if tool is .exe and we're on Linux/Mac) | |
| dscs_tools_path = Path(self.dscs_tools_path) | |
| if dscs_tools_path.suffix.lower() == '.exe' and platform.system() != "Windows": | |
| # Use Wine for Windows executables on non-Windows systems | |
| cmd = ['wine', self.dscs_tools_path, "--saveencrypt", source_path, target_path] | |
| self.logger.info("Using Wine to run Windows executable") | |
| else: | |
| # Use native execution | |
| cmd = [self.dscs_tools_path, "--saveencrypt", source_path, target_path] | |
| try: | |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) | |
| self.logger.info(f"Encryption successful: {target_path}") | |
| # Verify the file was actually created | |
| if Path(target_path).exists(): | |
| file_size = Path(target_path).stat().st_size | |
| self.logger.info(f"Target file created successfully, size: {file_size} bytes") | |
| return True | |
| else: | |
| self.logger.error(f"Target file was not created: {target_path}") | |
| return False | |
| except subprocess.CalledProcessError as e: | |
| self.logger.error(f"Encryption failed: {e}") | |
| self.logger.error(f"Command output: {e.stdout}") | |
| self.logger.error(f"Command error: {e.stderr}") | |
| return False | |
| def find_save_files(self, directory="."): | |
| """Find all save files in the specified directory.""" | |
| directory = Path(directory) | |
| save_pairs = [] | |
| for i in range(3): # 0000, 0001, 0002 | |
| main_save = directory / f"{i:04d}.bin" | |
| slot_save = directory / f"slot_{i:04d}.bin" | |
| if main_save.exists() and slot_save.exists(): | |
| save_pairs.append((str(main_save), str(slot_save))) | |
| self.logger.info( | |
| f"Found save pair: {main_save.name} and {slot_save.name}" | |
| ) | |
| else: | |
| if main_save.exists(): | |
| self.logger.warning( | |
| f"Found {main_save.name} but missing {slot_save.name}" | |
| ) | |
| if slot_save.exists(): | |
| self.logger.warning( | |
| f"Found {slot_save.name} but missing {main_save.name}" | |
| ) | |
| return save_pairs | |
| def convert_save( | |
| self, main_save_path, slot_save_path, output_dir=None, modified_dir=None | |
| ): | |
| """Convert Switch save files to PC format.""" | |
| if output_dir is None: | |
| output_dir = Path(main_save_path).parent / "output" | |
| if modified_dir is None: | |
| modified_dir = Path(main_save_path).parent / "modified" | |
| output_dir = Path(output_dir) | |
| output_dir.mkdir(exist_ok=True) | |
| try: | |
| # Validate input files | |
| self.validate_files(main_save_path, slot_save_path) | |
| # Copy files to modified folder for processing (originals stay untouched) | |
| main_modified, slot_modified = self.copy_files_for_processing( | |
| main_save_path, slot_save_path, modified_dir | |
| ) | |
| # Process the copies in modified folder | |
| self.process_main_save(main_modified) | |
| self.process_slot_save(slot_modified) | |
| # Encrypt the processed files and save to output folder | |
| main_encrypted = output_dir / Path(main_save_path).name | |
| slot_encrypted = output_dir / Path(slot_save_path).name | |
| main_success = self.encrypt_save(main_modified, str(main_encrypted)) | |
| slot_success = self.encrypt_save(slot_modified, str(slot_encrypted)) | |
| if main_success and slot_success: | |
| self.logger.info("=== Conversion Complete ===") | |
| self.logger.info( | |
| f"Original files preserved in: {Path(main_save_path).parent}" | |
| ) | |
| self.logger.info(f"Modified files saved to: {modified_dir}") | |
| self.logger.info(f"Encrypted files saved to: {output_dir}") | |
| return True | |
| else: | |
| self.logger.error("=== Conversion Failed ===") | |
| self.logger.error("One or more files failed to encrypt properly.") | |
| return False | |
| except Exception as e: | |
| self.logger.error(f"Error during conversion: {e}") | |
| return False | |
| def convert_all_saves(self, input_dir=".", output_dir=None, modified_dir=None): | |
| """Convert all save files found in the input directory.""" | |
| input_dir = Path(input_dir) | |
| if output_dir is None: | |
| output_dir = input_dir / "output" | |
| if modified_dir is None: | |
| modified_dir = input_dir / "modified" | |
| output_dir = Path(output_dir) | |
| output_dir.mkdir(exist_ok=True) | |
| modified_dir = Path(modified_dir) | |
| modified_dir.mkdir(exist_ok=True) | |
| # Find all save file pairs | |
| save_pairs = self.find_save_files(input_dir) | |
| if not save_pairs: | |
| self.logger.warning("No save file pairs found in the directory") | |
| return False | |
| self.logger.info(f"Found {len(save_pairs)} save file pairs to convert") | |
| successful_conversions = 0 | |
| failed_conversions = 0 | |
| for main_save, slot_save in save_pairs: | |
| self.logger.info( | |
| f"=== Converting {Path(main_save).name} and {Path(slot_save).name} ===" | |
| ) | |
| try: | |
| success = self.convert_save( | |
| main_save, slot_save, output_dir, modified_dir | |
| ) | |
| if success: | |
| successful_conversions += 1 | |
| self.logger.info(f"[SUCCESS] Converted {Path(main_save).name}") | |
| else: | |
| failed_conversions += 1 | |
| self.logger.error( | |
| f"[FAILED] Could not convert {Path(main_save).name}" | |
| ) | |
| except Exception as e: | |
| failed_conversions += 1 | |
| self.logger.error(f"[ERROR] Converting {Path(main_save).name}: {e}") | |
| # Summary | |
| self.logger.info(f"=== Conversion Summary ===") | |
| self.logger.info(f"Total save pairs found: {len(save_pairs)}") | |
| self.logger.info(f"Successful conversions: {successful_conversions}") | |
| self.logger.info(f"Failed conversions: {failed_conversions}") | |
| if successful_conversions > 0: | |
| self.logger.info(f"Original files preserved in: {input_dir}") | |
| self.logger.info(f"Modified files saved to: {modified_dir}") | |
| self.logger.info(f"Encrypted files saved to: {output_dir}") | |
| return failed_conversions == 0 | |
| def main(): | |
| """Main function to handle command line usage.""" | |
| parser = argparse.ArgumentParser( | |
| description="Convert Digimon Story Cyber Sleuth save files from Switch to PC format", | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s # Convert all saves in current directory | |
| %(prog)s --input ./savedata # Convert all saves in specified directory | |
| %(prog)s --main 0000.bin --slot slot_0000.bin # Convert specific files | |
| %(prog)s --verbose # Enable debug logging | |
| %(prog)s --output ./converted # Custom output directory | |
| """, | |
| ) | |
| # Input options | |
| input_group = parser.add_mutually_exclusive_group() | |
| input_group.add_argument( | |
| "--input", | |
| "-i", | |
| type=str, | |
| default=".", | |
| help="Input directory containing save files (default: current directory)", | |
| ) | |
| input_group.add_argument( | |
| "--main", | |
| "-m", | |
| type=str, | |
| help="Specific main save file (e.g., 0000.bin) - requires --slot", | |
| ) | |
| parser.add_argument( | |
| "--slot", | |
| "-s", | |
| type=str, | |
| help="Specific slot save file (e.g., slot_0000.bin) - requires --main", | |
| ) | |
| # Output options | |
| parser.add_argument( | |
| "--output", | |
| "-o", | |
| type=str, | |
| help="Output directory for encrypted files (default: input_dir/output)", | |
| ) | |
| parser.add_argument( | |
| "--modified", | |
| type=str, | |
| help="Directory for modified files (default: input_dir/modified)", | |
| ) | |
| # Tool options | |
| parser.add_argument( | |
| "--tools-path", | |
| type=str, | |
| help="Path to DSCSToolsCLI executable (auto-downloads if not specified)", | |
| ) | |
| # Logging options | |
| parser.add_argument( | |
| "--verbose", "-v", action="store_true", help="Enable verbose (debug) logging" | |
| ) | |
| parser.add_argument( | |
| "--quiet", "-q", action="store_true", help="Suppress most output (errors only)" | |
| ) | |
| args = parser.parse_args() | |
| # Validate specific file arguments | |
| if args.main and not args.slot: | |
| parser.error("--main requires --slot to be specified") | |
| if args.slot and not args.main: | |
| parser.error("--slot requires --main to be specified") | |
| # Set up logging based on verbosity | |
| if args.quiet: | |
| log_level = logging.ERROR | |
| elif args.verbose: | |
| log_level = logging.DEBUG | |
| else: | |
| log_level = logging.INFO | |
| logging.basicConfig( | |
| level=log_level, | |
| format="%(asctime)s - %(levelname)s - %(message)s", | |
| handlers=[ | |
| logging.StreamHandler(), | |
| logging.FileHandler("dscs_converter.log", encoding="utf-8"), | |
| ], | |
| ) | |
| try: | |
| converter = DSCSSaveConverter(dscs_tools_path=args.tools_path) | |
| if args.main and args.slot: | |
| # Convert specific files | |
| print(f"Converting specific files: {args.main} and {args.slot}") | |
| success = converter.convert_save( | |
| args.main, args.slot, output_dir=args.output, modified_dir=args.modified | |
| ) | |
| else: | |
| # Convert all saves in directory | |
| if args.input == ".": | |
| print("Converting all save files in current directory...") | |
| else: | |
| print(f"Converting all save files in directory: {args.input}") | |
| success = converter.convert_all_saves( | |
| input_dir=args.input, output_dir=args.output, modified_dir=args.modified | |
| ) | |
| sys.exit(0 if success else 1) | |
| except KeyboardInterrupt: | |
| print("\nOperation cancelled by user") | |
| sys.exit(130) | |
| except Exception as e: | |
| logging.error(f"Unexpected error: {e}") | |
| if args.verbose: | |
| import traceback | |
| traceback.print_exc() | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Author
Dunno how much help I can be, this worked right off the bat for me - the only thing I messed with was making it work on Linux... you should probably ask for help on some forum like GBATemp? I found this thread about it - https://gbatemp.net/threads/digimon-cyber-sleuth-complete-collection-save-editor.550647
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
hey, im sorry for bothering you but I don't know what exactly happened but the whenever I tried to open the converted savedata from switch in PC the game always crash. The cmd clearly displayed save convertion to be success tho.
Can you help me?