Skip to content

Instantly share code, notes, and snippets.

@udance4ever
Last active April 25, 2026 21:41
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 & ease integration w ES-DE (macOS) esp for steam, xbox360 & ps4; implements .squashfs support
#!/opt/homebrew/bin/python3
# April 8, 2026 (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 root search path (NOTE: need to tailor below)
# NOTES:
#!/usr/bin/env python3 fails launched *inside* ES-DE.app
# default PATH -> /usr/bin:/bin:/usr/sbin:/sbin { doesn't include homebrew }
import argparse
import atexit
import configparser
import os
import re
import shlex
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
# command line args
# -----------------
parser = argparse.ArgumentParser()
parser.add_argument("--system", help='emulator system (use "squashfsmount" to mount & exit)')
parser.add_argument("--tmpmount", help='force mount .squashfs in /tmp', default=False, action='store_true')
parser.add_argument("--binary", type=Path, help='path to custom binary')
parser.add_argument("--alt", help='select alt binary (if known)', default=False, action='store_true')
parser.add_argument("--intel", help='select Intel executable in Universal binary (macOS)', default=False, action='store_true')
parser.add_argument("--romsearch", help='use rom root search path', default=False, action='store_true')
parser.add_argument("--exec", help="exec generated cmd (--no-exec still mounts .squashfs)", default=True, action=argparse.BooleanOptionalAction)
parser.add_argument("--debug", help='debug mode', default=False, action='store_true')
parser.add_argument("file", type=Path, 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}/ES-DE.app/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
# RetroArch (macOS) mappings
# $$TODO: eval "MESS" adam, apple2e, coco, macintosh under RetroArch MAME (see fmtowns)
_cores = {
"3do": "opera",
"amiga500": "puae",
"atari2600": "stella",
"atomiswave": "flycast",
"dos" : "dosbox_pure",
"dreamcast": "flycast",
"easyrpg" : "easyrpg",
"fbneo": "fbneo",
"gameandwatch": "mame", # default: RetroArch core (use --alt for standalone MAME)
"gamegear": "genesis_plus_gx",
"gb": "gambatte",
"gb2players": "tgbdual",
"gbc": "gambatte",
"gbc2players": "tgbdual",
"gba": "mgba",
"jaguar": "virtualjaguar",
"lutro": "lutro",
"lynx": "handy",
"mame": "mame",
"mastersystem": "genesis_plus_gx",
"megadrive": "genesis_plus_gx",
"model2": "mame", # default: RetroArch core (use --alt for standalone MAME)
"msu-md": "genesis_plus_gx",
"msx2": "bluemsx",
"msxturbor": "bluemsx",
"n64": "mupen64plus_next",
"naomi" : "flycast",
"naomi2" : "flycast",
"naomigd" : "flycast",
"nds": "desmume",
"neogeo": "fbneo",
"neogeocd": "neocd",
"nes": "fceumm",
"ngp": "mednafen_ngp",
"ngpc": "mednafen_ngp",
"o2em": "o2em",
"palm": "mu",
"pc98": "np2kai",
"pcengine": "mednafen_pce",
"pcenginecd": "mednafen_pce",
"pcfx": "mednafen_pcfx",
"prboom": "prboom",
"psx": "mednafen_psx",
"saturn": "mednafen_saturn",
"satellaview": "snes9x",
"sega32x": "picodrive",
"segacd": "genesis_plus_gx",
"sg1000": "genesis_plus_gx",
"sgb": "mgba",
"snes": "snes9x",
"snes-msu1": "snes9x",
"supergrafx": "mednafen_supergrafx",
"superbroswar": "superbroswar",
"uzebox": "uzem",
"vectrex": "vecx",
"virtualboy": "mednafen_vb",
"wasm4": "wasm4",
"wswan": "mednafen_wswan",
"wswanc": "mednafen_wswan",
"x68000": "px68k",
"zxspectrum": "fuse",
}
# functions
# ---------
mount = None
def exit_handler():
if not mount:
return
if not mount.exists():
return
if not mount.is_mount():
return
if args.system == "squashfsmount":
return
print("INFO: Unmounting and removing:", mount)
subprocess.run(["umount", str(mount)])
mount.rmdir()
if args.tmpmount:
(tmpmount / emusystem).rmdir()
tmpmount.rmdir()
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, notExistsOK=False):
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) or notExistsOK:
if args.debug: print(f"DEBUG> ESDEFindRule: ({object}, {key}) -> {binary}")
return Path(binary)
return None
def emusystem_from_path(p: Path) -> str | None:
p = Path(p).absolute()
child = None
for ancestor in (p, *p.parents):
parent = ancestor.name
if parent == "roms" and child:
return child
child = parent or child
return p.parent.name or None # fallback: immediate parent (or None if root)
def search_in_path(p: Path, search_path: str, base:Path=None, ignoreStubs=False, exitNotFound=False) -> Path:
if args.debug: print(f"DEBUG: search_in_path: {search_path}")
for path in search_path.split(':'):
p_abs = Path(path) / base / p
if args.debug: print(f"DEBUG: search_in_file: checking {p_abs}")
if p_abs.exists() and (p_abs.stat().st_size != 0 or not ignoreStubs):
print(f"Found in search path: {p_abs}")
return p_abs
break
else:
print(f"Not found in search path: {p}")
if exitNotFound: exit(1)
return None
def squashmount(file: Path, emusystem: str, exitOnMount: bool=False):
mount = Path(file.with_suffix('')) # remove .squashfs
doMount = not (exitOnMount and not args.exec) # mount if *not* squashfsmount and --no-exec
# handle special mount locations
match emusystem:
case "ps3":
# redirect mount to dev_hdd0/game if "[hdd0,<serial>]" in filename
serial = reGroup1(r"\[hdd0,([A-Za-z0-9_]+)\]", str(mount))
if serial:
mount = Path(_devpath) / serial
case "ps4":
# ps4: redirect mount to <serial> (4 uppercase letters + 5 digits)
serial = reGroup1(r"([A-Z]{4}[0-9]{5})", str(mount))
if not serial:
print("ERROR: no serial in filename to mount title. Exiting")
exit(1)
mount = Path(mount).resolve().parent / serial
# https://stackoverflow.com/a/2113511/9983389
if (not os.access(str(Path(mount).parent), os.W_OK)) or args.tmpmount:
tmpmount = Path(f"/tmp/emulatorLauncher.{os.getpid()}")
mount = tmpmount / emusystem / Path(mount).name
print("INFO: mount:", mount)
if doMount:
if not mount.exists():
mount.mkdir(parents=True, exist_ok=True)
elif mount.is_mount():
print("INFO: mount exists. unmounting:", mount)
subprocess.run(["umount", str(mount)])
cmd = ['/usr/local/bin/squashfuse', file, mount]
if args.debug: print("DEBUG> cmd:", cmd)
try:
if doMount: subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print("ERROR: Failed to mount:", file)
sys.exit(e.returncode)
if doMount:
print("INFO: permissions:", "".join(ch if os.access(str(mount), flag) else "-" for ch,flag in (("r",os.R_OK),("w",os.W_OK),("x",os.X_OK))))
if not os.access(str(mount), os.R_OK | os.X_OK):
print(f"ERROR: Mounted file system inaccessible: {mount}")
print(" You may need to recreate using `mksquashfs source FILESYSTEM -all-root`")
exit(1)
if exitOnMount: exit(0)
singlefile = mount / mount.name
# $$ wierd bug testing existence inside squashfs mounts (SMB related?)
if not singlefile.exists() and args.debug:
print(f'DEBUG> workaround: checking "{singlefile}" exists in squashfs 2x this session')
# https://stackoverflow.com/a/2507871/9983389
if singlefile.exists() and singlefile.stat().st_size != 0:
if args.debug: print("DEBUG> found non-zero singlefile:", singlefile)
return mount, singlefile
else:
return mount, mount
# setup
# -----
file = args.file
if args.system:
emusystem, _, emu_cli = args.system.partition(":")
else:
emusystem, emu_cli = emusystem_from_path(file), None
file = search_in_path(file.name, _romrootpath, base=emusystem, ignoreStubs=True, exitNotFound=True) if args.romsearch else file.absolute()
if file.suffix == '.squashfs':
if emusystem == "squashfsmount":
squashmount(file, emusystem_from_path(file), exitOnMount=True)
else:
mount, rompath = squashmount(file, emusystem)
else:
rompath = file
if args.debug: print(f"DEBUG: emusystem={emusystem}; emu_cli={emu_cli}; rompath={rompath}")
# generate cmd by system
# ----------------------
# $ alternative to big match statement: generator classes (akin to Batocera Linux configgen)
mameRompath = f'{rompath.parent};{_biosdir}/mame;{_biosdir}'
messRompath = f'{rompath.parent};{_biosdir}/{emusystem}'
match emusystem:
# NOTE: RetroArch core is used by default if mapping exists
case sys if (sys in _cores) and not args.alt:
appexec = ESDEFindRule('emulator', 'RETROARCH')
cmdArgs = [ '-L', ESDEFindRule('core', 'RETROARCH') / f'{_cores[sys]}_libretro.dylib' ]
if mount:
if sys == "snes-msu1": rompath = next(rompath.glob('*.sfc'))
if sys == "msu-md": rompath = next(rompath.glob('*.md'))
cmdArgs += [ rompath ]
case "adam" | "apple2" | "coco":
appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME
cmdArgs = [ '-rompath', messRompath,
emusystem if "apple2" not in emusystem else 'apple2e',
'-ui_active',
'-joystick' if "apple2" not in emusystem else '-gameio', 'joy',
'-flop1' if "coco" not in emusystem else '-cart',
rompath ]
os.chdir(f'{_savedir}/mame')
case "macintosh":
appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME
cmdArgs = [ f'-rompath {messRompath}',
'macplus',
'-ui_active',
'-flop1',
rompath ]
os.chdir(f'{_savedir}/mame')
case "apple2gs":
configfile = Path(os.getenv('HOME')) / '.config.gsp'
config = configparser.ConfigParser(allow_unnamed_section=True)
config.read(configfile)
config.set(configparser.UNNAMED_SECTION, 's7d1', str(rompath))
with configfile.open('w') as cf: config.write(cf)
appexec = ESDEFindRule('emulator', 'GSPLUS')
cmdArgs = [ ]
case "3ds":
cci_map = {"citra": False, "lime3ds": False, "azahar": True} # default is first
emu = next((k for k in cci_map if emu_cli and k in emu_cli), next(iter(cci_map)))
appexec = ESDEFindRule('emulator', emu.upper())
cmdArgs = [ rompath if cci_map[emu] else rompath.with_suffix(".3ds") ]
case "dreamcast" | "atomiswave" | "naomi" | "naomi2" | "naomigd":
appexec = ESDEFindRule('emulator', 'FLYCAST') # NOTE: --alt selects standalone Flycast
cmdArgs = [ rompath ]
case "daphne" | "singe":
mediadir = rompath.parent if rompath.name == rompath.parent.name else (mount or rompath)
basename = mediadir.name.removesuffix(".daphne")
datadir = Path(_configspath) / "hypseus-singe"
script = mediadir / f"{basename}.singe"
singe = True if script.exists() else False
commands = mediadir/ f"{basename}.commands"
extraOpts = commands.read_text(encoding="utf-8") if commands.exists() else None
appexec = ESDEFindRule('emulator', 'HYPSEUS-SINGE') # datadir / "bin" / "hypseus.bin"
cmdArgs = [
'singe' if singe else basename,
'vldp',
'-fullscreen',
'-gamepad',
'-framefile', mediadir / (basename + ".txt"),
'-datadir', datadir,
'-homedir', datadir,
]
if extraOpts: cmdArgs += shlex.split(extraOpts)
if singe: cmdArgs += [ '-script', script,
'-singedir', mediadir.parent ]
case "flash":
appexec = ESDEFindRule('emulator', 'RUFFLE')
cmdArgs = [ "--fullscreen", rompath ]
case "fmtowns":
driver = 'fmtownshr'
mameArgs = [ driver, '-rompath', messRompath, '-cdrom', f'"{rompath}"' ]
appexec = ESDEFindRule('emulator', 'RETROARCH')
cmdArgs = [ '-L', ESDEFindRule('core', 'RETROARCH') / 'mame_libretro.dylib', ' '.join(mameArgs) ]
case "gameandwatch":
appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME
cmdArgs = [ '-skip_gameinfo', '-artpath', f'{_biosdir}/mame/artwork', '-rompath', mameRompath, rompath.stem ]
os.chdir(f'{_savedir}/mame')
case "gamecube" | "wii" | "triforce":
appexec = ESDEFindRule('emulator', 'DOLPHIN' if not args.alt else 'DOLPHIN-TRIFORCE')
cmdArgs = [ '-b', '-e', rompath ]
if args.alt: cmdArgs += [ '-u', Path(_configspath) / "Dolphin (Triforce)" ]
case "hikaru":
appexec = ESDEFindRule('emulator', 'WHISKYCMDRAW')
# https://forum.arcadecontrols.com/index.php/topic,106954.msg1133325.html
cmdArgs = [ ESDEFindRule('bottle', "EMU2"), ESDEFindRule('emulator', 'DEMUL', notExistsOK=True),
f'-run={emusystem}', f'-rom={rompath.stem}' ]
case "ikemen":
appexec = ESDEFindRule('emulator', 'OS-SHELL')
cmdArgs = [ '-c', rompath ]
case "model2": # $$ controller bindings not working on macOS (NOTE: RA core is default above)
appexec = ESDEFindRule('emulator', 'MAME') # standalone MAME
cmdArgs = [ '-skip_gameinfo', '-rompath', mameRompath, rompath.stem ]
os.chdir(f'{_savedir}/mame')
case "model3":
appexec = ESDEFindRule('emulator', 'SUPERMODEL')
cmdArgs = [ rompath ]
os.chdir(ESDEFindRule('config', 'SUPERMODEL'))
case "namco2x6":
appexec = ESDEFindRule('emulator', 'PLAY!')
cmdArgs = [ '--fullscreen', '--arcade', rompath.stem ]
case "openbor":
appexec = ESDEFindRule('emulator', 'WHISKYCMDRAW')
cmdArgs = [ ESDEFindRule('bottle', 'EMU2'), ESDEFindRule('emulator', 'OPENBOR', notExistsOK=True), rompath ]
os.chdir(ESDEFindRule('config', 'OPENBOR'))
case "ps2":
appexec = ESDEFindRule('emulator', 'PCSX2')
cmdArgs = [ '-fullscreen', '-nogui', rompath ]
case "ps3":
if rompath.suffix == '.psn':
rompath = Path(_devpath) / rompath.read_text(encoding="utf-8").rstrip()
appexec = ESDEFindRule('emulator', 'RPCS3' if not args.intel else 'RPCS3_INTEL')
cmdArgs = [ rompath ]
case "ps4":
appexec = ESDEFindRule('emulator', 'SHADPS4')
cmdArgs = [ '--fullscreen', 'true', rompath / "eboot.bin" ]
case "psp":
appexec = ESDEFindRule('emulator', 'PPSSPPGOLD' if not args.alt else 'PPSSPP')
cmdArgs = [ rompath ]
case "psvita":
appexec = ESDEFindRule('emulator', 'VITA3K')
cmdArgs = [ '-F', '-w', '-f', '-r', reGroup1(r"\[([A-Za-z0-9_]+)\]", rompath.stem) ]
case "scummvm":
appexec = ESDEFindRule('emulator', 'SCUMMVM')
cmdArgs = [ '-f', f'--path={rompath}', next(rompath.glob("*.scummvm")).stem ]
case "sdlpop":
appexec = ESDEFindRule('emulator', 'SDLPOP')
cmdArgs = [ 'full' ]
case "solarus":
appexec = ESDEFindRule('emulator', 'SOLARUS')
cmdArgs = [ '-fullscreen=yes', rompath ]
case "steam":
appexec = ESDEFindRule('emulator', 'WHISKYSTEAMHEROIC')
cmdArgs = [ open(rompath, 'r', encoding='utf-8').read() ]
case "steam.macOS":
appexec = ESDEFindRule('emulator', 'OPEN') # not OS-SHELL on macOS
cmdArgs = [ '-W', '-a', rompath ]
os.chdir(rompath.parent) # ensure rel symlinks to .app work
case "switch":
arg_map = {"ryubing": [rompath], "eden": ['-f', '-g', rompath]} # first is default
emu = next((k for k in arg_map if emu_cli and k in emu_cli), next(iter(arg_map)))
appexec = ESDEFindRule('emulator', emu.upper())
cmdArgs = arg_map[emu]
case "vpinball":
appexec = ESDEFindRule('emulator', 'VISUAL-PINBALL')
cmdArgs = [ '-play', rompath ]
case "wiiu":
appexec = ESDEFindRule('emulator', 'CEMU')
cmdArgs = [ "-f", '-g', rompath ]
case "windows":
appexec = ESDEFindRule('emulator', 'WHISKYCMDRAW')
cmdArgs = [ ESDEFindRule('bottle', 'EMU2'), rompath ]
# $TODO akin to apple2gs, update xemu.toml to update hdd_path on a per system/game basis
case "xbox" | "chihiro":
appexec = ESDEFindRule('emulator', 'XEMU')
cmdArgs = [ '-dvd_path', rompath ]
# NOTE: no longer uses --alt (migrated to xbox360:<emu>)
case "xbox360":
appexec = ESDEFindRule('emulator', (emu_cli or 'WHISKYXENIA').upper())
if rompath.suffix == '.xbox360':
os.chdir(rompath.parent) # stubs are relative
xex = Path(rompath.read_text(encoding="utf-8").rstrip("\r\n"))
if args.romsearch:
xex = search_in_path(xex, _romrootpath, base=emusystem, exitNotFound=True)
elif mount:
xex = rompath / 'default.xex'
if not xex.exists(): # not common: some discs do not have default.xex
xex = next(rompath.glob("*.xex"))
else:
xex = rompath
cmdArgs = [ xex ]
case "lindbergh":
print(f"FUTURE: requires macOS port: {emusystem}")
exit(1)
case "openlara" | "pyxel":
print(f"FUTURE: requires macOS port (RetroArch core): {emusystem}")
exit(1)
case "gong":
print(f"ERROR: not yet supported: {emusystem}")
exit(1)
case _:
if args.alt:
print(f'ERROR: No alternative emulator known for system: {emusystem}')
else:
print(f'ERROR: Unknown system: {emusystem} {"(Use --system SYSTEM to be explicit)" if not args.system else ""}')
exit(1)
appArgs = [ args.binary or appexec ]
if args.intel: appArgs = ['arch', '-x86_64', *appArgs]
cmd = appArgs + cmdArgs
if args.debug: print("DEBUG: cmd[]:", cmd)
print("INFO: cmd:", shlex.join([str(e) for e in cmd])) # shell version
# execute command
# ---------------
if args.exec:
try:
res = subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"{cmd[0]} failed to exit cleanly (status={e.returncode})")
except FileNotFoundError:
print(f"{cmd[0]}: command not found")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment