Last active
December 26, 2025 21:01
-
-
Save udance4ever/ced9d3734c3ee958c4ad6ffecf4e02ce to your computer and use it in GitHub Desktop.
script to launch roms from Terminal.app, implements squashfs support, xbox360 support and ease integration w ES-DE (currently macOS)
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
| #!/opt/homebrew/bin/python3 | |
| # Dec 26, 2025 (created: Feb 5, 2025) | |
| # | |
| # emulatorLauncher helper script used to | |
| # 1) make it easy to launch roms from CLI (Terminal.app) | |
| # 2) implement .squashfs support (compatible with Batocera Linux) | |
| # 3) ease integration with ES-DE (esp in macOS) | |
| # 4) implement rom search | |
| # NOTES: | |
| #!/usr/bin/env python3 fails (PATH in ES-DE doesn't include python3 by default) | |
| # PATH -> /usr/bin:/bin:/usr/sbin:/sbin | |
| import argparse | |
| import atexit | |
| import os, shutil | |
| import re | |
| import configparser | |
| from pathlib import Path | |
| import xml.etree.ElementTree as ET | |
| # command line args | |
| # ----------------- | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--system", help='emulator system (use "squashfsmount" for mount tool)') | |
| parser.add_argument("--retroarch", help='prefer RetroArch', default=True, action=argparse.BooleanOptionalAction) | |
| parser.add_argument("--tmpmount", help='mount .squashfs in /tmp', default=False, action=argparse.BooleanOptionalAction) | |
| #parser.add_argument("--devmount", help='ps3 NPS mount in "rpcs3/dev_hdd0/game"', default=False, action='store_true') | |
| parser.add_argument("--app", help='specify custom binary') | |
| parser.add_argument("--intel", help='select Intel executable in Universal binary (macOS)', default=False, action='store_true') | |
| parser.add_argument("--exec", help="don't exec generated cmd (still temp mounts .squashfs)", default=True, action=argparse.BooleanOptionalAction) | |
| parser.add_argument("--debug", help='debug mode', default=False, action='store_true') | |
| parser.add_argument("--romsearch", help='use rom root search path', default=False, action=argparse.BooleanOptionalAction) | |
| parser.add_argument("file", help='rom file, squashfs archive or stub (eg. .psvita)') | |
| args = parser.parse_args() | |
| # constants | |
| # --------- | |
| _apppath = '/Applications/#emu' | |
| _ESDEdir = '/userdata/system/configs/ES-DE' | |
| _findrules = [ | |
| f'{_ESDEdir}/custom_systems/es_find_rules.xml', # custom has priority | |
| f'{_apppath}/Contents/Resources/resources/systems/macos/es_find_rules.xml' | |
| ] | |
| _configspath = '/userdata/system/configs' | |
| _devpath = f'{_configspath}/rpcs3/dev_hdd0/game/' | |
| _romsdir = '/userdata/roms' | |
| _biosdir = '/userdata/bios' | |
| _savedir = '/userdata/saves' | |
| # rom root search path ($$ edit for your environment) | |
| _devdir = '/Volumes/MEDIA_DEV' # 1TB NVMe in USB-C enclosure | |
| _favdir = '/Volumes/SHARE_FAV' # 512GB micro SD | |
| _360dir = '/Volumes/EVAL' # 1TB HDD G-Drive (BadUpdate) | |
| _nasdir = '/Volumes/SHARE' # NAS (/mnt/Pool1/shares/Share) | |
| _romrootpath_default = ":".join([ | |
| f"{_romsdir}:{_romsdir}.local:{_romsdir}.eval", # local | |
| f"{_devdir}/roms", | |
| f"{_favdir}/roms", | |
| f"{_360dir}/roms:{_360dir}/roms.CONSOLE:{_360dir}/roms.emu:{_360dir}/roms.ports", | |
| f"{_nasdir}/roms", | |
| ]) | |
| _romrootpath = os.environ.get('ROMROOT_PATH') or _romrootpath_default | |
| if args.debug: print(f"DEBUG> _romrootpath: {_romrootpath}") | |
| # RetroArch (macOS) mappings | |
| _cores = { | |
| "3do": "opera", | |
| "atari2600": "stella", | |
| "fbneo": "fbneo", | |
| "gameandwatch": "mame", # default: RetroArch MAME core | |
| "gb": "gambatte", | |
| "gb2players": "tgbdual", | |
| "gbc": "gambatte", | |
| "gba": "mgba", | |
| "mame": "mame", | |
| "mastersystem": "genesis_plus_gx", | |
| "megadrive": "genesis_plus_gx", | |
| "model2": "mame", # default: RetroArch MAME core | |
| "n64": "mupen64plus_next", | |
| "nds": "desmume", | |
| "neogeo": "fbneo", | |
| "neogeocd": "neocd", | |
| "nes": "mesen", | |
| "pc98": "np2kai", | |
| "pcengine": "mednafen_pce", | |
| "pcenginecd": "mednafen_pce", | |
| "pcfx": "mednafen_pcfx", | |
| "psx": "mednafen_psx", | |
| "saturn": "mednafen_saturn", | |
| "sgb": "mgba", | |
| "segacd": "picodrive", | |
| "sega32x": "picodrive", | |
| "snes": "snes9x", | |
| "x68000": "px68k", | |
| } | |
| # functions | |
| # --------- | |
| mount = None | |
| def exit_handler(): | |
| if not mount: | |
| return | |
| if not os.path.exists(mount): | |
| return | |
| if not os.path.ismount(mount): | |
| return | |
| if args.system == "squashfsmount": | |
| return | |
| print("INFO> Unmounting and removing:", mount) | |
| os.system(f'umount "{mount}"') | |
| os.rmdir(mount) | |
| if args.tmpmount: | |
| os.rmdir(f'{tmpmount}/{emusystem}') | |
| os.rmdir(tmpmount) | |
| atexit.register(exit_handler) | |
| def reGroup1(pattern, string): | |
| # https://stackoverflow.com/a/8569258/9983389 | |
| m = re.search(pattern, string) | |
| return m.group(1) if m else None | |
| def ESDEFindRule(object, key): | |
| for findrule in _findrules: | |
| for item in ET.parse(findrule).findall(object): | |
| name = item.attrib.get('name') | |
| if name != key: | |
| continue | |
| rule = item.find('rule') | |
| if rule is None: | |
| continue | |
| for entry in rule.findall('entry'): | |
| binary = (entry.text or "").strip() | |
| if rule.attrib.get('type') == 'systempath': | |
| binary = shutil.which(binary) | |
| if os.path.exists(binary): | |
| if args.debug: print(f"DEBUG> ESDEFindRule: ({object}, {key}) -> {binary}") | |
| return binary | |
| return None | |
| # setup | |
| # ----- | |
| file = args.file | |
| emusystem = args.system if args.system else str(Path(file).absolute().parent.name) | |
| if args.debug: print(f"DEBUG> emusystem: {emusystem}") | |
| # $$ NOTE: romsearch done (by default) for zero-length stubs | |
| romsearch = args.romsearch or os.path.getsize(file) == 0 | |
| if romsearch: | |
| for path in _romrootpath.split(':'): | |
| absfile = f'{path}/{emusystem}/{os.path.basename(file)}' | |
| if os.path.exists(absfile) and os.path.getsize(absfile) != 0: | |
| print(f"Found in romrootpath: {absfile}") | |
| file = absfile | |
| break | |
| else: | |
| print(f"ERROR: {file} not found in: {_romrootpath}") | |
| exit(1) | |
| elif not os.path.exists(file): | |
| print("ERROR: file does not exist:", file) | |
| exit(1) | |
| # squashfs mounting | |
| # ----------------- | |
| # $TODO: convert this block into shared function returning rompath | |
| if file.endswith(".squashfs"): | |
| (mount, ext) = os.path.splitext(file) | |
| # ps3: redirect mount to dev_hdd0/game if "[hdd0,<serial>]" in filename | |
| if (emusystem == "ps3") or (emusystem == "squashfsmount"): | |
| serial = reGroup1(r"\[hdd0,([A-Za-z0-9_]+)\]", mount) | |
| if serial: | |
| mount = _devpath + serial | |
| # ps4: redirect mount to <serial> (4 uppercase letters + 5 digits) | |
| if emusystem == "ps4": | |
| serial = reGroup1(r"([A-Z]{4}[0-9]{5})", mount) | |
| if not serial: | |
| print("ERROR: no serial in filename to mount title. Exiting") | |
| exit(1) | |
| mount = os.path.dirname(os.path.abspath(mount)) + "/" + serial | |
| # https://stackoverflow.com/a/2113511/9983389 | |
| if (not os.access(os.path.dirname(os.path.realpath(mount)), os.W_OK)) or args.tmpmount: | |
| tmpmount = f"/tmp/emulatorLauncher.{os.getpid()}" | |
| basename = os.path.basename(mount) | |
| mount = f"{tmpmount}/{emusystem}/{basename}" | |
| print("INFO> mount:", mount) | |
| if not os.path.exists(mount): | |
| os.makedirs(mount, exist_ok=True) | |
| elif os.path.ismount(mount): | |
| print("INFO> mount exists. unmounting:", mount) | |
| os.system(f"umount '{mount}'") | |
| cmd = f'/usr/local/bin/squashfuse "{file}" "{mount}"' | |
| if args.debug: print("DEBUG> cmd:", cmd) | |
| status = os.system(cmd) | |
| if status != 0: | |
| print("ERROR: Failed to mount:", file) | |
| exit(1) | |
| r = "r" if os.access(mount, os.R_OK) else "-" | |
| w = "w" if os.access(mount, os.W_OK) else "-" | |
| x = "x" if os.access(mount, os.X_OK) else "-" | |
| print(f"INFO> permissions: {r}{w}{x}") | |
| if not os.access(mount, os.R_OK | os.X_OK): | |
| print(f"ERROR: Mounted file system inaccessible ({r}{w}{x}): {mount}") | |
| print(" You may need to run `mksquashfs source FILESYSTEM -all-root`") | |
| exit(1) | |
| singlefile = f"{mount}/{os.path.basename(mount)}" | |
| # https://stackoverflow.com/a/2507871/9983389 | |
| if os.path.exists(singlefile) and os.stat(singlefile).st_size != 0: | |
| if args.debug: print("DEBUG> found non-zero singlefile:", singlefile) | |
| rompath = singlefile | |
| else: | |
| rompath = mount | |
| else: | |
| rompath = file | |
| if emusystem == "squashfsmount": exit(0) | |
| # generate cmd by system | |
| # ---------------------- | |
| # $ alternative to big match statement: generator classes (akin to Batocera Linux configgen) | |
| (romDir, romFilename) = os.path.split(rompath) | |
| gameDir = os.path.abspath(romDir) | |
| romBasename = os.path.splitext(romFilename)[0] | |
| cmd = None | |
| match emusystem: | |
| case sys if args.retroarch and (sys in _cores): | |
| appexec = ESDEFindRule('emulator', 'RETROARCH') | |
| cmdArgs = [ '-L', f'{ESDEFindRule('core', 'RETROARCH')}/{_cores[sys]}_libretro.dylib', | |
| f'"{os.path.abspath(rompath)}"' ] | |
| case "apple2gs": | |
| configfile = f"{os.getenv('HOME')}/.config.gsp" | |
| config = configparser.ConfigParser(allow_unnamed_section=True) | |
| config.read(configfile) | |
| config.set(configparser.UNNAMED_SECTION, 's7d1', args.file) | |
| with open(configfile, 'w') as cf: | |
| config.write(cf) | |
| appexec = ESDEFindRule('emulator', 'GSPLUS') | |
| cmdArgs = [ ] | |
| # Structural Pattern Matching requires Python 3.10+ | |
| # https://stackoverflow.com/a/76838213/9983389 | |
| case str(x) if "3ds" in x: | |
| appexec = ESDEFindRule('emulator', 'CITRA') | |
| cci = False | |
| if "lime3ds" in emusystem: | |
| appexec = ESDEFindRule('emulator', 'LIME3DS') | |
| cci = False | |
| if "azahar" in emusystem: | |
| appexec = ESDEFindRule('emulator', 'AZAHAR') | |
| cci = True | |
| if not cci: rompath = rompath.replace('.cci', '.3ds') | |
| cmdArgs = [ f'"{os.path.realpath(rompath)}"' ] | |
| case "atomiswave" | "dreamcast" | "naomi" | "naomi2": | |
| appexec = ESDEFindRule('emulator', 'FLYCAST') | |
| cmdArgs = [ f'"{rompath}"' ] | |
| case "daphne" | "singe": | |
| realdir = os.path.realpath(rompath) | |
| basename = os.path.basename(file.removesuffix(".squashfs")) | |
| basename = basename.removesuffix(".daphne") | |
| datadir = Path(f"{_configspath}/hypseus-singe") | |
| script = f"{realdir}/{basename}.singe" | |
| singe = True if (os.path.exists(script)) else False | |
| commands = f"{realdir}/{basename}.commands" | |
| extraOpts = "" | |
| if (os.path.exists(commands)): | |
| with open(commands) as f: extraOpts = f.read() | |
| # appexec = str(datadir / "bin" / "hypseus.bin") | |
| appexec = ESDEFindRule('emulator', 'HYPSEUS-SINGE') | |
| cmdArgs = [ | |
| 'singe' if singe else basename, | |
| 'vldp', | |
| '-fullscreen', | |
| '-gamepad', | |
| '-framefile', f"{realdir}/{basename}.txt", | |
| '-datadir', str(datadir), | |
| '-homedir', str(datadir), | |
| extraOpts | |
| ] | |
| if singe: cmdArgs += [ "-script", script, | |
| "-singedir", os.path.dirname(realdir) ] | |
| case "flash": | |
| appexec = ESDEFindRule('emulator', 'RUFFLE') | |
| cmdArgs = [ "--fullscreen", f'"{os.path.abspath(rompath)}"' ] | |
| case "fmtowns": | |
| driver = 'fmtownshr' | |
| mameRompath = fr'"{gameDir}\;{_biosdir}/{emusystem}"' | |
| mameArgs = [ driver, '-rompath', f"{mameRompath}", '-cdrom', f"{os.path.abspath(rompath)}" ] | |
| appexec = ESDEFindRule('emulator', 'RETROARCH') | |
| cmdArgs = [ '-L', f'{ESDEFindRule('core', 'RETROARCH')}/mame_libretro.dylib', | |
| f'"{' '.join(mameArgs)}"' ] | |
| case "gameandwatch": | |
| appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME | |
| cmdArgs = [ '-skip_gameinfo', f'-artpath {_biosdir}/mame/artwork', f'-rompath {gameDir} {romBasename}' ] | |
| os.chdir(f'{_savedir}/mame') | |
| case "gamecube" | "wii": | |
| appexec = ESDEFindRule('emulator', 'DOLPHIN') | |
| cmdArgs = [ '-b', '-e', f'"{rompath}"' ] | |
| case "model2": # $$ controller bindings not working on macOS (RA core overrides above) | |
| appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME | |
| mameRompath = fr'{gameDir}\;{_biosdir}/mame\;{_biosdir}' | |
| cmdArgs = [ '-skip_gameinfo', f'-rompath {mameRompath} {romBasename}' ] | |
| os.chdir(f'{_savedir}/mame') | |
| case "model3": | |
| appexec = ESDEFindRule('emulator', 'SUPERMODEL') | |
| cmdArgs = [ f'"{os.path.abspath(rompath)}"' ] | |
| os.chdir(ESDEFindRule('config', 'SUPERMODEL')) | |
| case "namco2x6": | |
| appexec = ESDEFindRule('emulator', 'PLAY') | |
| cmdArgs = [ '--fullscreen', '--arcade', romBasename ] | |
| case "ps2": | |
| appexec = ESDEFindRule('emulator', 'PCSX2') | |
| cmdArgs = [ '-fullscreen', '-nogui', f'"{rompath}"' ] | |
| case "ps3": | |
| if rompath.endswith(".psn"): | |
| with open(rompath) as f: serial = f.read().rstrip() | |
| rompath = _devpath + serial | |
| # bundle = 'RPCS3 (Intel).app' if args.intel else 'RPCS3.app' | |
| appexec = ESDEFindRule('emulator', 'RPCS3') # $$ ignores bundle | |
| cmdArgs = [ f'"{rompath}"' ] | |
| case "ps4": | |
| appexec = ESDEFindRule('emulator', 'SHADPS4') | |
| cmdArgs = [ '--fullscreen true', | |
| f'"{os.path.abspath(rompath) + "/eboot.bin"}"' ] | |
| case "psp": | |
| appexec = ESDEFindRule('emulator', 'PPSSPPGOLD') | |
| cmdArgs = [ f'"{rompath}"' ] | |
| case "psvita": | |
| title = os.path.basename(rompath).removesuffix('.psvita') | |
| appexec = ESDEFindRule('emulator', 'VITA3K') | |
| cmdArgs = [ '-F', '-w', '-f', '-r', | |
| reGroup1(r"\[([A-Za-z0-9_]+)\]", title) ] | |
| case "scummvm": | |
| appexec = ESDEFindRule('emulator', 'SCUMMVM') | |
| cmdArgs = [ '-f', f'--path="{rompath}"', | |
| f'"{next(Path(rompath).glob("*.scummvm")).stem}"' ] | |
| case "sdlpop": | |
| appexec = ESDEFindRule('emulator', 'SDLPOP') | |
| cmdArgs = [ 'full' ] | |
| case "msu-md": | |
| appexec = ESDEFindRule('emulator', 'RETROARCH') | |
| cmdArgs = [ '-L', f'{ESDEFindRule('core', 'RETROARCH')}/genesis_plus_gx_libretro.dylib', | |
| f'"{next(Path(rompath).glob("*.md")) if mount else rompath}"' ] | |
| case "snes-msu1": | |
| appexec = ESDEFindRule('emulator', 'RETROARCH') | |
| cmdArgs = [ '-L', f'{ESDEFindRule('core', 'RETROARCH')}/snes9x_libretro.dylib', | |
| f'"{next(Path(rompath).glob("*.sfc")) if mount else rompath}"' ] | |
| case "steam": | |
| appexec = ESDEFindRule('emulator', 'WHISKYSTEAMHEROIC') | |
| cmdArgs = [ open(rompath, 'r', encoding='utf-8').read() ] | |
| case "steam.macOS": | |
| appexec = "open" # $$ ESDEFindRule('emulator', 'OS-SHELL') is not accurate | |
| cmdArgs = [ '-W', '-a', f'"{rompath}"' ] | |
| os.chdir(os.path.dirname(rompath) or ".") # ensure rel symlinks to .app work | |
| case "switch": | |
| appexec = ESDEFindRule('emulator', 'RYUJINX') | |
| cmdArgs = [ f'"{rompath}"' ] | |
| case "vpinball": | |
| appexec = ESDEFindRule('emulator', 'VISUAL-PINBALL') | |
| cmdArgs = [ f'-play "{rompath}"' ] | |
| case "wiiu": | |
| appexec = ESDEFindRule('emulator', 'CEMU') | |
| cmdArgs = [ "-f", f'-g "{rompath}"' ] | |
| case "xbox" | "chihiro": | |
| appexec = ESDEFindRule('emulator', 'XEMU') | |
| cmdArgs = [ '-dvd_path', f'"{rompath}"' ] | |
| case "xbox360": | |
| appexec = ESDEFindRule('emulator', 'WHISKYXENIA') | |
| if rompath.endswith('.xbox360'): | |
| os.chdir(os.path.dirname(rompath) or ".") # stubs are relative | |
| xex = open(rompath, 'r', encoding='utf-8').read().rstrip('\r\n') | |
| elif mount: | |
| xex = f'{rompath}/default.xex' | |
| if not os.path.exists(xex): # $$ not common | |
| xex = f'{next(Path(rompath).glob("*.xex"))}' | |
| else: | |
| xex = rompath | |
| cmdArgs = [ f'"{xex}"' ] | |
| case "coco" | "palm": | |
| print(f"FUTURE: coming soon!: {emusystem}") | |
| exit(1) | |
| case "lindbergh" | "triforce": | |
| print(f"FUTURE: requires macOS port: {emusystem}") | |
| exit(1) | |
| case "hikaru" | "openbor" | "windows": | |
| print(f"FUTURE: integrate with WhiskyCmd.sh (see WhiskyXenia.sh): {emusystem}") | |
| exit(1) | |
| case _: | |
| print(f'ERROR: Unsupported system: {emusystem} {"(Use --system SYSTEM to be explicit)" if not args.system else ""}') | |
| exit(1) | |
| if args.app: appexec = f'"{args.app}"' | |
| app = appexec if not args.intel else f'arch -x86_64 {appexec}' | |
| cmd = ' '.join([app] + cmdArgs) | |
| # execute command | |
| # --------------- | |
| print("INFO> cmd:", cmd) | |
| if args.exec: | |
| status = os.system(cmd) | |
| if status != 0: | |
| print(f"{cmd.split()[0]} failed to exit cleanly (status={status})") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment