diff --git a/README.md b/README.md index 0bb81ae..fdb5a96 100644 --- a/README.md +++ b/README.md @@ -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// +python extract.py + +# Game name mode: outputs to out/// +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/ + └── +``` + +**With `--game-names`:** +``` +out/ +└── Dead Cells/ + └── 0100646009FBE000/ + └── +``` + +Game names are sanitized to ASCII (e.g. `Mario Kart™ 8 Deluxe` → `Mario Kart 8 Deluxe`) for compatibility with JKSV. diff --git a/extract.py b/extract.py index 8adf87f..59a5718 100644 --- a/extract.py +++ b/extract.py @@ -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"): @@ -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///) 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 \ No newline at end of file