Skip to content

Instantly share code, notes, and snippets.

@udance4ever
Last active December 26, 2025 21:01
Show Gist options
  • Select an option

  • Save udance4ever/ced9d3734c3ee958c4ad6ffecf4e02ce to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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