Last active
June 9, 2026 06:24
-
-
Save sgtlaggy/d209703236f96831bf3c7fda570849a4 to your computer and use it in GitHub Desktop.
Unpack SPT mods that are packed with explicit backslash paths, resulting in a flat list of files.
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 | |
| import argparse | |
| import subprocess | |
| import sys | |
| import zipfile | |
| from pathlib import Path | |
| SCRIPT_DIR = Path(__file__).parent | |
| SCRIPT_IN_SPT = (SCRIPT_DIR / "EscapeFromTarkov.exe").exists() | |
| PARSER = argparse.ArgumentParser(description="Unpack bad-behaving SPT mods.") | |
| PARSER.add_argument( | |
| "-o", | |
| "--output-dir", | |
| default=SCRIPT_DIR if SCRIPT_IN_SPT else None, | |
| type=Path, | |
| help="Path to folder that contains EscapeFromTarkov.exe.", | |
| ) | |
| PARSER.add_argument( | |
| "-M", | |
| "--no-move", | |
| action="store_true", | |
| help="Leave extracted files in `output-dir/tmp`.", | |
| ) | |
| PARSER.add_argument("zip", nargs="+") | |
| ARGS = PARSER.parse_args() | |
| def main(): # noqa: C901 | |
| for arg in ARGS.zip: | |
| fp = Path(arg) | |
| if not fp.exists(): | |
| print(f"Error: {arg} does not exist.", file=sys.stderr) | |
| continue | |
| out: Path | None = ARGS.output_dir | |
| if out is None: | |
| out = fp.with_suffix("") | |
| n = 0 | |
| while out.exists(): | |
| n += 1 | |
| out = fp.with_suffix(f".{n}") | |
| tmp = out / "tmp" | |
| match fp.suffix.casefold(): | |
| case ".zip": | |
| try: | |
| unzip(fp, tmp) | |
| except Exception as e: | |
| print(e, file=sys.stderr) | |
| case ".7z" | ".rar": | |
| try: | |
| subprocess.run( # noqa: S603 | |
| ["7z", "x", f"-o{tmp}", fp], # noqa: S607 | |
| stdout=subprocess.DEVNULL, | |
| ) | |
| except Exception as e: | |
| print(e, file=sys.stderr) | |
| if not ARGS.output_dir: | |
| cleanup_tmp(tmp) | |
| if ARGS.output_dir: | |
| cleanup_tmp(tmp) | |
| def unzip(fp: Path, out: Path): | |
| with zipfile.ZipFile(fp) as zf: | |
| for member in zf.namelist(): | |
| # ignore directories | |
| if member.endswith("\\"): | |
| continue | |
| zf.extract(member, out) | |
| # clean mod, should have extracted normally | |
| if "\\" not in member: | |
| continue | |
| def cleanup_tmp(tmp: Path): | |
| fix_backslashes_in(tmp) | |
| fix_casing(tmp) | |
| if ARGS.no_move: | |
| return | |
| unpack_tmp(tmp) | |
| def fix_backslashes_in(tmp: Path): | |
| for fp in tmp.iterdir(): | |
| if "\\" not in fp.name: | |
| continue | |
| if fp.is_dir(): | |
| try_rmdir(fp) | |
| continue | |
| elif fp.name.endswith("\\"): | |
| fp.unlink() | |
| continue | |
| fixed = tmp / fp.name.replace("\\", "/") | |
| fixed.parent.mkdir(parents=True, exist_ok=True) | |
| fp.move(fixed) | |
| def fix_casing(tmp: Path): | |
| _fix_casing(tmp, ["BepInEx", "SPT"]) | |
| bepinex = tmp / "BepInEx" | |
| if bepinex.exists(): | |
| _fix_casing(bepinex, ["patchers", "plugins"]) | |
| spt = tmp / "SPT" | |
| if not spt.exists(): | |
| return | |
| _fix_casing(spt, ["user", "SPT_Data"]) | |
| user = spt / "user" | |
| if user.exists(): | |
| _fix_casing(user, ["mods"]) | |
| # never seen a mod use this, but supposedly it’s a valid case | |
| data = spt / "SPT_Data" | |
| if data.exists(): | |
| _fix_casing(data, ["Launcher"]) | |
| def _fix_casing(root: Path, dir_names: list[str]): | |
| for fp in root.iterdir(): | |
| for name in dir_names: | |
| if is_wrong_case(fp.name, name): | |
| movetree(fp, root / name) | |
| def is_wrong_case(string: str, reference: str) -> bool: | |
| return string != reference and string.casefold() == reference.casefold() | |
| def unpack_tmp(tmp: Path): | |
| known_dirs = {"BepInEx", "SPT", "EscapeFromTarkov_Data"} | |
| for fp in tmp.iterdir(): | |
| if fp.is_dir() and fp.name in known_dirs: | |
| try: | |
| movetree(fp, tmp.parent / fp.name) | |
| except Exception as e: | |
| print(e, file=sys.stderr) | |
| elif fp.suffix.casefold() == ".exe": | |
| fp.move_into(tmp.parent) | |
| try_rmdir(tmp) | |
| def movetree(src: Path, dst: Path): | |
| if not dst.exists(): | |
| src.move(dst) | |
| return | |
| for fp in src.iterdir(): | |
| new_fp = dst / fp.relative_to(src) | |
| if fp.is_dir(): | |
| movetree(fp, new_fp) | |
| try_rmdir(fp) | |
| else: | |
| new_fp.parent.mkdir(parents=True, exist_ok=True) | |
| fp.move(new_fp) | |
| try_rmdir(src) | |
| def try_rmdir(fp: Path): | |
| try: | |
| fp.rmdir() | |
| except FileNotFoundError: | |
| pass | |
| except Exception as e: | |
| print(e, file=sys.stderr) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment