Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save joaociocca/1d6442c329616b260a0490ed4efc6502 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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()
@dyudarizkia69
Copy link

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?

@joaociocca
Copy link
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