webstation-broker
DeveloperPython referenceemulators

emulators.retroarch

RetroArch launcher for any libretro core.

RetroArch launcher for any libretro core.

Covers the platform to core mapping, the buildbot core download, and the stdin command protocol for save, state and quit.

This is the general purpose provider: instead of one class per emulator we keep a map from RomM platform slug to libretro core.

RetroArch is the user's own desktop app, so we never touch its config file. The launch uses --appendconfig <broker.cfg> to layer in only the protocol-required settings on top of the user's config: the stdin command interface, and the broker-managed save directories so the save archive logic tracks real files.

Control plane: RetroArch's stdin command interface (config key stdin_cmd_enable) reads newline-delimited commands from stdin and writes replies to stdout. Commands:

  • SAVE_STATE: no reply; dispatches CMD_EVENT_SAVE_STATE from the runloop (the save-state hotkey path).
  • LOAD_STATE_SLOT <n>: bare echo LOAD_STATE_SLOT <n> (no success bit).
  • STATE_SLOT_PLUS: no reply; current slot +1.
  • STATE_SLOT_MINUS: no reply; current slot -1, floored at -1 (auto).
  • SAVE_FILES: OK / NO (newline-terminated).
  • GET_STATUS: GET_STATUS PLAYING <core_id>,<basename> (newline-terminated) or GET_STATUS CONTENTLESS.
  • DISK_EJECT_TOGGLE: open or close the virtual tray.
  • DISK_NEXT: step to the next disc in the loaded playlist.
  • QUIT: no reply; queued for the runloop.

Saves are confirmed on the filesystem instead of from a reply.

Save slots: there is no "save to slot n" command. SAVE_STATE writes whichever slot is current, nothing reports which that is, and a state_slot in the appended config does not survive content load (verified on 1.22.2: pinning 10 still wrote slot 0). None of that matters much here, because RomM keeps the library of states and this only ever works in STATE_SLOT. What RetroArch does have is that hard floor at -1, so counting MINUS presses down to it and PLUS presses back up parks the slot absolutely. That runs once per launch, and again only if a save lands somewhere else, which is the one thing that can happen: the player cycling slots with their own hotkeys.

State file naming:

  • <content_basename>.state for slot 0.
  • <content_basename>.state<n> for slot n.
  • <content_basename>.state.auto for slot -1.
  • Each save writes a <name>.png thumbnail beside the state file.
  • SRAM: <content_basename>.srm under the savefile dir.

Because stdout carries only command replies here, the child is spawned with a real stdout pipe drained by a reader thread, unlike the shared _spawn which merges stderr into stdout (that would corrupt the reply stream).

attributelog
= logging.getLogger(__name__)
attributeROM_ROOT
= Path(os.environ.get('ROM_ROOT', '/romm'))

Root of the RomM library mount, from ROM_ROOT (default /romm).

A ROM candidate has to resolve to somewhere under it to be booted.

attributeXDG_DATA_HOME
= os.environ.get('XDG_DATA_HOME') or str(Path.home() / '.local/share')

The user's data home, from XDG_DATA_HOME (default ~/.local/share).

attributeRA_CONFIG_DIR
= Path(os.environ.get('RETROARCH_CONFIG_DIR', str(Path.home() / '.config' / 'retroarch')))

The user's RetroArch config directory, from RETROARCH_CONFIG_DIR (default ~/.config/retroarch).

attributeCORES_DIR
= Path(os.environ.get('RETROARCH_CORES_DIR') or _configured_dir('libretro_directory') or Path(XDG_DATA_HOME) / 'RetroArch' / 'cores')

Where libretro cores are installed and loaded from.

Taken from RETROARCH_CORES_DIR, else the libretro_directory in the user's config, else $XDG_DATA_HOME/RetroArch/cores. A core has to land in the dir RetroArch also reads .info files from. Loading one from anywhere else leaves its core info unset, and GET_STATUS then segfaults RetroArch mid-session (1.22.2). Following the user's own libretro_directory is also what makes a downloaded core show up in the desktop RetroArch without its in-app core downloader.

attributeSYSTEM_DIR
= Path(os.environ.get('RETROARCH_SYSTEM_DIR') or _configured_dir('system_directory') or Path(XDG_DATA_HOME) / 'RetroArch' / 'system')

Where cores look for the assets and firmware they cannot ship themselves.

Taken from RETROARCH_SYSTEM_DIR, else the system_directory in the user's config, else $XDG_DATA_HOME/RetroArch/system.

attributeCORES_BASE_URL
= os.environ.get('RETROARCH_CORES_BASE_URL', 'https://buildbot.libretro.com/nightly/linux/x86_64/latest')

Base URL cores are downloaded from, from RETROARCH_CORES_BASE_URL.

Defaults to the libretro buildbot's latest linux x86_64 nightly. The buildbot ships every core as <core>_libretro.so.zip.

attributeGITHUB_API_BASE
= os.environ.get('GITHUB_API_BASE', 'https://api.github.com').rstrip('/')

GitHub API root, from GITHUB_API_BASE (default https://api.github.com).

Cores the buildbot does not carry name their own release source instead, and their releases are looked up here.

attributeRA_DATA_DIR
= Path(os.environ.get('RETROARCH_DATA_DIR', '/config/.retroarch'))

Root of the broker-managed RetroArch data, from RETROARCH_DATA_DIR.

Defaults to /config/.retroarch. Holds the save data and the append-config we layer onto the user's RetroArch config at launch.

attributeSTATE_DIR
= RA_DATA_DIR / 'states'

The broker-managed savestate directory, states under RA_DATA_DIR.

attributeSAVE_DIR
= RA_DATA_DIR / 'saves'

The broker-managed savefile (SRAM) directory, saves under RA_DATA_DIR.

attributeBROKER_CFG
= RA_DATA_DIR / 'broker.cfg'

The append-config written per launch, broker.cfg under RA_DATA_DIR.

attributeRA_LOG_PATH
= Path(os.environ.get('RETROARCH_LOG_PATH', '/config/retroarch.log'))

Where RetroArch's stderr is appended, from RETROARCH_LOG_PATH (default /config/retroarch.log).

attributeSAVE_FILES_WAIT
= float(os.environ.get('RETROARCH_SAVE_FILES_WAIT', '10.0'))

Seconds to wait for the SAVE_FILES reply at exit, from RETROARCH_SAVE_FILES_WAIT (default 10).

attributeSTATE_CONFIRM_WAIT
= float(os.environ.get('RETROARCH_STATE_CONFIRM_WAIT', '10.0'))

Seconds a save gets to land on disk, from RETROARCH_STATE_CONFIRM_WAIT (default 10).

attributeQUIT_WAIT
= float(os.environ.get('RETROARCH_QUIT_WAIT', '10.0'))

Seconds the second QUIT gets before SIGTERM, from RETROARCH_QUIT_WAIT (default 10).

attributeQUIT_CONFIRM_GAP
= float(os.environ.get('RETROARCH_QUIT_CONFIRM_GAP', '0.1'))

Seconds the first QUIT gets before a second press, from RETROARCH_QUIT_CONFIRM_GAP (default 0.1).

attributeRESUME_LOAD_WAIT
= float(os.environ.get('RETROARCH_RESUME_WAIT', '90.0'))

Seconds a deferred resume load waits for a running game and a state file.

From RETROARCH_RESUME_WAIT (default 90).

attributeRESUME_LOAD_SETTLE
= float(os.environ.get('RETROARCH_RESUME_SETTLE', '3.0'))

Seconds between the core reporting PLAYING and the load, from RETROARCH_RESUME_SETTLE (default 3).

attributeLOAD_ACK_WAIT
= float(os.environ.get('RETROARCH_LOAD_ACK_WAIT', '10.0'))

Seconds to wait for the LOAD_STATE_SLOT echo, from RETROARCH_LOAD_ACK_WAIT (default 10).

attributeSTATE_SLOT
= int(os.environ.get('RETROARCH_STATE_SLOT', '0'))

The one slot the broker works in, from RETROARCH_STATE_SLOT (default 0).

0 is RetroArch's own default, so a state written here is also the one the player's own load hotkey reaches for.

attributeSLOT_STEP_DELAY
= float(os.environ.get('RETROARCH_SLOT_STEP_DELAY', '0.1'))

Seconds between slot-homing presses, from RETROARCH_SLOT_STEP_DELAY (default 0.1).

The pause is what keeps RetroArch from dropping presses.

attributeSLOT_HOME_STEPS
= int(os.environ.get('RETROARCH_SLOT_HOME_STEPS', '24'))

STATE_SLOT_MINUS presses used to home the slot, from RETROARCH_SLOT_HOME_STEPS (default 24).

The step count has to outrun any slot the player could have cycled to.

attributeDISC_TRAY_SETTLE
= float(os.environ.get('RETROARCH_DISC_TRAY_SETTLE', '1.5'))

Seconds the tray gets to open before discs are stepped, from RETROARCH_DISC_TRAY_SETTLE (default 1.5).

The settle is not optional: RetroArch drops a disc index change that arrives while the tray is still opening, and the failure is silent (the old disc stays mounted).

attributeDISC_STEP_DELAY
= float(os.environ.get('RETROARCH_DISC_STEP_DELAY', '0.1'))

Seconds between DISK_NEXT presses, from RETROARCH_DISC_STEP_DELAY (default 0.1).

attributeDISC_SWAP_WAIT
= float(os.environ.get('RETROARCH_DISC_SWAP_WAIT', '90.0'))

How long a swap waits for the core to report a running game, from RETROARCH_DISC_SWAP_WAIT.

Defaults to 90 seconds. A mid-session swap answers on the first poll; a swap issued right after launch waits out the boot.

attributeCORE_DOWNLOAD_TIMEOUT
= float(os.environ.get('RETROARCH_CORE_DOWNLOAD_TIMEOUT', '180'))

HTTP timeout for core downloads and release lookups, from RETROARCH_CORE_DOWNLOAD_TIMEOUT.

Defaults to 180 seconds.

attributeJOYPAD_DRIVER
= os.environ.get('RETROARCH_JOYPAD_DRIVER', 'linuxraw')

The joypad driver forced at launch, from RETROARCH_JOYPAD_DRIVER (default linuxraw).

The pads here are Selkies interposer sockets, not real devices, and the fake libudev behind them gives every one the same identity. RetroArch's udev joypad driver reads that as one device plugged eight times ("Device ID 0 is already plugged") and ends up with no pads at all. linuxraw opens the js nodes directly, which the interposer does hook, so there is nothing to collide on. Set empty to leave the user's own driver alone.

attributePLATFORMSdict[str, dict[str, Any]]
= _load_platforms()

RomM platform slug to libretro core, loaded from _PLATFORMS_FILE.

Each entry names the core and its extensions; the extensions order doubles as the preference order when a folder holds several candidates.

savestate is assumed true; only specialized cores opt out.

thumbnail is assumed true. Cores that render on the GPU can deadlock RetroArch's main loop on the framebuffer grab that follows a save, which takes the stdin command channel down with it for the rest of the session.

assets maps a path under the RetroArch system dir to the directory on the image holding those files, for cores that need data the .so does not carry.

core_source names where a core the buildbot does not carry comes from, and save_subtrees narrows the save archive for cores whose savefile dir is also their app-data dir.