Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,50 @@
Modification of suchmememanyskill's SaveExtract script to turn saves extracted from the user partition to saves injectable through a homebrew app such as [JKSV](https://github.com/J-D-K/JKSV).

Huge thanks for [suchmememanyskill](https://github.com/suchmememanyskill) for the original script!

## Requirements

- Python 3.6+
- [hactool](https://github.com/SciresM/hactool) installed and available in PATH (Linux) or as `hactool.exe` in the script directory (Windows)
- `prod.keys` file placed in the SaveExtract directory

## Usage

Place your save files in a `save/` directory, then run:

```bash
# Default mode: outputs to out/<TitleID>/
python extract.py

# Game name mode: outputs to out/<Game Name>/<TitleID>/
python extract.py --game-names

# Game name mode without edition suffixes (Switch 2 games use same folder as Switch):
python extract.py --game-names --strip-edition
```

### Options

| Flag | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `--game-names` | Organize saves by game name using the [titledb](https://github.com/blawar/titledb) database. Downloads `US.en.json` automatically if needed. |
| `--strip-edition` | Remove edition suffixes (e.g. ` – Nintendo Switch 2 Edition`) from game names, so Switch and Switch 2 versions share the same folder. |

### Output structure

**Default:**
```
out/
└── 0100646009FBE000/
└── <save data>
```

**With `--game-names`:**
```
out/
└── Dead Cells/
└── 0100646009FBE000/
└── <save data>
```

Game names are sanitized to ASCII (e.g. `Mario Kart™ 8 Deluxe` → `Mario Kart 8 Deluxe`) for compatibility with JKSV.
96 changes: 86 additions & 10 deletions extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,47 @@
Script originally written by suchmememanyskill and modified by HunterMario to add Unix support
"""

import subprocess, os
import subprocess, os, urllib.request, json, re, argparse


def downloadTitleDb(dest="US.en.json") -> str:
url = "https://raw.githubusercontent.com/blawar/titledb/master/US.en.json"
# Check remote file size and skip download if local file matches
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req) as resp:
remote_size = int(resp.headers.get("Content-Length", -1))
if os.path.exists(dest) and remote_size > 0:
local_size = os.path.getsize(dest)
if local_size == remote_size:
print("Title database is up to date, skipping download.")
return dest
urllib.request.urlretrieve(url, dest)
return dest

def loadTitleDb(path="US.en.json") -> dict:
"""Load title DB and return a dict mapping title ID (uppercase) to game name."""
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
lookup = {}
for entry in data.values():
tid = entry.get("id")
name = entry.get("name")
if tid and name:
lookup[tid.upper()] = name
return lookup

def sanitizeDirName(name: str, strip_edition: bool = False) -> str:
"""Sanitize game name for path use, matching JKSV behavior (strip non-ASCII and invalid chars)."""
if strip_edition:
name = re.sub(r'\s*[–-]\s*Nintendo Switch\s*\d*\s*Edition', '', name)
# Remove non-ASCII characters (™, ®, ©, etc.)
name = name.encode("ascii", "ignore").decode("ascii")
# Remove filesystem-invalid characters
name = re.sub(r'[<>:"/\\|?*]', '', name)
# Collapse multiple spaces and strip
name = re.sub(r' +', ' ', name).strip()
return name

def findInText(txt, find) -> str:
if os.name == "posix":
for x in txt.split("\\n"):
Expand Down Expand Up @@ -36,30 +74,68 @@ def getHactoolOutput(fileName : str) -> str:
return getHactoolOutputUnix(fileName)

def getHactoolOutputWindows(fileName : str) -> str:
return str(subprocess.check_output(["hactool.exe", "-k", "prod.keys", "-t", "save" , f"save/{fileName}", "--outdir", f"out/{fileName}"]))
output = subprocess.check_output(["hactool.exe", "-k", "prod.keys", "-t", "save" , f"save/{fileName}", "--outdir", f"out/{fileName}"], stderr=subprocess.STDOUT)
with open("hactool.log", "a", encoding="utf-8") as log:
log.write(f"=== {fileName} ===\n")
log.write(output.decode("utf-8", errors="replace"))
log.write("\n")
return str(output)

def getHactoolOutputUnix(fileName : str) -> str:
return str(subprocess.check_output(["hactool", "-k", "prod.keys", "-t", "save" , f"save/{fileName}", "--outdir", f"out/{fileName}"]))
output = subprocess.check_output(["hactool", "-k", "prod.keys", "-t", "save" , f"save/{fileName}", "--outdir", f"out/{fileName}"], stderr=subprocess.STDOUT)
with open("hactool.log", "a", encoding="utf-8") as log:
log.write(f"=== {fileName} ===\n")
log.write(output.decode("utf-8", errors="replace"))
log.write("\n")
return str(output)


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Extract Nintendo Switch save files.")
parser.add_argument("--game-names", action="store_true",
help="Organize output by game name (out/<game name>/<ID>/) instead of just ID")
parser.add_argument("--strip-edition", action="store_true",
help="Remove edition suffixes (e.g. ' – Nintendo Switch 2 Edition') from game names")
args = parser.parse_args()

hactoolPrep()

titleDb = {}
if args.game_names:
print("Downloading title database...")
downloadTitleDb()
titleDb = loadTitleDb()

directory = os.fsencode("./save")
os.makedirs("out", exist_ok=True)
for file in os.listdir(directory):
fileName = os.fsdecode(file)

print(f"Extracting {fileName}")
text = getHactoolOutput(fileName)
# findInText() may raise a ValueError if "Title ID" is not found in the file
try:
portion = findInText(text, "Title ID:").upper()
print(f"{fileName} => {portion}")
if (os.path.exists(f"out/{portion}")):
titleId = findInText(text, "Title ID:").upper()

if args.game_names:
gameName = titleDb.get(titleId)
if gameName:
gameName = sanitizeDirName(gameName, strip_edition=args.strip_edition)
destDir = f"out/{gameName}/{titleId}"
else:
print(f" Warning: Title ID {titleId} not found in title database")
destDir = f"out/{titleId}"
os.makedirs(os.path.dirname(destDir), exist_ok=True)
else:
destDir = f"out/{titleId}"

if os.path.exists(destDir):
dirnumber = 1
while (os.path.exists(f"out/{portion}_{dirnumber}")):
while os.path.exists(f"{destDir}_{dirnumber}"):
dirnumber += 1
os.rename(f"out/{fileName}", f"out/{portion}_{dirnumber}")
os.rename(f"out/{fileName}", f"{destDir}_{dirnumber}")
else:
os.rename(f"out/{fileName}", f"out/{portion}")
os.rename(f"out/{fileName}", destDir)

print(f" {fileName} => {destDir}")
except ValueError:
pass