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 and loads are confirmed on the filesystem instead of from a reply: a save by the state file it writes, a load by the access time of the state file it reads.

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, under states/<library_name>/, the dir RetroArch sorts each core's states into (library_name is the name the core reports):

  • <content_basename>.state for slot 0.
  • <content_basename>.state<n> for slot n.
  • <content_basename>.state.auto for slot -1.
  • SRAM: <content_basename>.srm under saves/<library_name>/.

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.

attributeRA_BASE_DIR
= xdg_config_dir('retroarch')

RetroArch's own directory root: $XDG_CONFIG_HOME/retroarch, else $HOME/.config/retroarch.

Every directory RetroArch has no explicit setting for hangs off this one root on Linux, whatever that directory holds. Probed against the container's build (1.22.2): started on a config naming none of them, RetroArch saved libretro_directory and libretro_info_path as cores under this root, and system_directory, savefile_directory and savestate_directory as system, saves and states under it.

attributeRA_CONFIG_PATH
= _resolve_config_path()

The user's retroarch.cfg, under RETROARCH_CONFIG_DIR when that is set.

Safe to move, because the launch states it: --config names this file on RetroArch's own command line. Without that the knob would move only the file the broker reads the cores and system directories out of, while RetroArch kept loading the one its own search order found.

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

Where libretro cores are installed and loaded from.

Taken from RETROARCH_CORES_DIR, else the libretro_directory in the user's config, else cores under RA_BASE_DIR, which is RetroArch's own default libretro_directory. RetroArch only gives a core its core info when the core sits in its libretro_directory and the .info in its libretro_info_path, so the broker config pins both here. Without it, GET_STATUS 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 RA_BASE_DIR / '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 system under RA_BASE_DIR, which is where RetroArch looks when nothing configures it.

Safe to move, because the launch states it: _write_broker_cfg puts this path in system_directory, and --appendconfig gives that priority over the user's config. Without that the knob would drop the firmware and the linked core assets into a directory no core ever opens.

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.

attributeCORE_OPTIONS_CFG
= RA_DATA_DIR / 'broker-core-options.cfg'

The per-core option pins written per launch, broker-core-options.cfg under RA_DATA_DIR.

_write_broker_cfg points core_options_path at this file and forces global_core_options/game_specific_options so it always applies; see _write_core_options.

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).

The platform table's state_confirm_wait overrides this per platform, for cores whose state files are large enough that this default is too tight.

attributeSAVE_LOCK_WAIT
= float(os.environ.get('RETROARCH_SAVE_LOCK_WAIT', '30.0'))

Seconds a save waits for an in-flight resume load or disc swap, from RETROARCH_SAVE_LOCK_WAIT.

Defaults to 30. A save queues rather than failing fast (the way a second disc swap does) because the state it would write mid-resume is the pre-resume one, and the resume then loads that back over the player's own save.

attributeEXIT_SAVE_LOCK_WAIT
= float(os.environ.get('RETROARCH_EXIT_SAVE_LOCK_WAIT', '3.0'))

Seconds the exit save waits for the tray, from RETROARCH_EXIT_SAVE_LOCK_WAIT.

Defaults to 3, far below SAVE_LOCK_WAIT. The exit runs under the API's process-wide session lock, so every second spent here is a second every other session route answers 409 for. A resume load still holding the tray at exit is already off the rails, and waiting out the full save budget inside that lock would not make it any likelier to finish.

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).

attributeRESUME_LOAD_RETRY_GAP
= float(os.environ.get('RETROARCH_RESUME_RETRY_GAP', '2.0'))

Seconds between resume load attempts, from RETROARCH_RESUME_RETRY_GAP (default 2).

A core that echoed the load but never read the state was not ready for it, so the next attempt is worth spacing out rather than firing back immediately.

attributeRESUME_ATTEMPT_LOCK_WAIT
= float(os.environ.get('RETROARCH_RESUME_LOCK_WAIT', '15.0'))

Seconds one resume load attempt waits for the tray, from RETROARCH_RESUME_LOCK_WAIT.

Defaults to 15, which covers a save running to its own confirmation timeout at the global STATE_CONFIRM_WAIT default. A platform whose state_confirm_wait override runs longer than that can make one attempt give up on a save still in flight; the retries drop the tray between attempts, so this bounds a single attempt rather than the whole resume budget, and the next attempt picks the tray up once the save releases it.

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).

attributeLOAD_CONFIRM_WAIT
= float(os.environ.get('RETROARCH_LOAD_CONFIRM_WAIT', '10.0'))

Seconds RetroArch gets to actually read the state file after the echo.

From RETROARCH_LOAD_CONFIRM_WAIT (default 10). The echo says the command was parsed, not that a state was restored; the read is what says that.

attributeLOAD_LOCK_WAIT
= float(os.environ.get('RETROARCH_LOAD_LOCK_WAIT', '30.0'))

Seconds a manual load waits for the tray, from RETROARCH_LOAD_LOCK_WAIT (default 30).

Matches SAVE_LOCK_WAIT: a manual load and a deferred resume attempt both back-date the same state file's access time and poll it to confirm their own read, so they must never run that confirmation concurrently, or either one can mistake the other's read for its own.

attributeLOAD_TAINT_RETRY_LIMIT
= int(os.environ.get('RETROARCH_LOAD_TAINT_RETRY_LIMIT', '2'))

Extra backdate-and-send cycles a tainted load gets, from RETROARCH_LOAD_TAINT_RETRY_LIMIT.

A handout landing inside the read wait taints that attempt for good under relatime (see _wait_for_state_read), so a fresh marker is the only way left to detect the load actually happening; without a retry here a load that RetroArch genuinely performed would surface to the caller as a plain failure whenever it raced a state-file handout.

attributeSTATE_READ_HANDOUT_GRACE
= float(os.environ.get('RETROARCH_STATE_HANDOUT_GRACE', '2.0'))

Seconds a handed-out state path stays suspect, from RETROARCH_STATE_HANDOUT_GRACE.

Defaults to 2, enough to cover the gap between a caller asking state_path for the file and its own read of it landing.

attributeSTATE_SLOT
= _state_slot_from_env()

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. -1 works in the auto slot, <content>.state.auto, instead: the homing presses stop on that floor, SAVE_STATE writes it, and LOAD_STATE_SLOT -1 reads it. The appended config keeps savestate_auto_save and savestate_auto_load off either way, so RetroArch never writes or loads that file behind the broker's back.

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.

attributeMENU_SETTLE
= float(os.environ.get('RETROARCH_MENU_SETTLE', '0.5'))

Seconds after a MENU_TOGGLE before the next command, from RETROARCH_MENU_SETTLE (default 0.5).

The menu swallows the hotkey-style commands (SAVE_STATE, the slot presses), so one sent before the toggle has taken effect is lost the same way.

attributeMENU_COMBO
= int(os.environ.get('RETROARCH_MENU_COMBO', '2'))

Pad combo that opens the menu, from RETROARCH_MENU_COMBO (default 2, L3 + R3).

An index into RetroArch's input_menu_toggle_gamepad_combo list, applied only when the user's own config sets no combo; 0 writes none. Without one, a player on a pad has no way into the menu: the room's pads have no F1 key. RetroArch reads the combo from every pad during play but only port 0 once the menu is up, so any player can open the menu but only player 1 or the room's button can close it.

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.

attributeFIRST_SAVE_SETTLE
= float(os.environ.get('RETROARCH_FIRST_SAVE_SETTLE', '3.0'))

Seconds a session's first save waits after PLAYING, from RETROARCH_FIRST_SAVE_SETTLE (default 3).

A fast-booting core (gambatte) can still be showing its own boot sequence (the Nintendo logo scroll, or a flat boot-transition frame) rather than the game's first real frame when the very first save of a session reaches _try_save: the state file confirms fine, but the frame captured with the save shows whatever is on screen at that instant. Counted from PLAYING rather than from process spawn, since core load and ROM load between spawn and PLAYING are themselves variable and would otherwise eat into (or blow past) the budget before the game has actually started. Only the first save pays this cost, since _slot_homed is already true for every later one; a session whose first save comes well after PLAYING naturally owes no wait at all.

attributeFIRST_SAVE_SETTLE_WAIT
= float(os.environ.get('RETROARCH_FIRST_SAVE_SETTLE_WAIT', '20.0'))

Seconds _wait_for_first_save_settle waits for a PLAYING timestamp, from RETROARCH_FIRST_SAVE_SETTLE_WAIT (default 20).

Covers a save requested before _track_first_playing has observed PLAYING even once, e.g. a very fast player or a slow-booting core; past this the save proceeds unsettled rather than blocking indefinitely on a core that never came up.

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).

attributeOBSERVE_WINDOW_BEFORE_SLACK
= float(os.environ.get('RETROARCH_OBSERVE_WINDOW_BEFORE_SLACK', '2.0'))

Seconds _observe_library_name backdates _launch_wall by, from RETROARCH_OBSERVE_WINDOW_BEFORE_SLACK (default 2.0).

Some network mounts (SMB, FAT) round a file's mtime to a coarse unit (as little as 2 seconds), so a state written right at launch can read back as a hair older than _launch_wall itself; without this slack that write would be mistaken for stale save data from a previous session.

attributeOBSERVE_WINDOW_AFTER_SLACK
= float(os.environ.get('RETROARCH_OBSERVE_WINDOW_AFTER_SLACK', '30.0'))

Seconds _observe_library_name extends past "now" by, from RETROARCH_OBSERVE_WINDOW_AFTER_SLACK (default 30.0).

The share and the broker's clocks are not guaranteed to agree; a mtime that is genuinely from this session but arrives slightly ahead of the broker's own clock should not be discarded as bogus. Wide enough to absorb ordinary skew, narrow enough that a stale future-dated file (a bad clock, a copied save) still fails the check.

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.

library_name is the name the core reports in retro_get_system_info. RetroArch names the sorted save and state dirs after it, and it is not always the core's file name: dolphin reports dolphin-emu.

save_ram is required, and says whether the core exposes RETRO_MEMORY_SAVE_RAM on this platform, so that RetroArch loads its battery save from <stem>.srm. Where it is false the core keeps its save in a file of its own, or has none, so an imported .srm would never reach the game.

sav_is_srm is optional, and true only where a device's .sav for this platform was checked to hold the same bytes, at the same offsets, as the core's .srm. There an import places a .sav as the .srm; it is renamed, never converted. An alternate states its own, and an untested core has none.

savestate is assumed true; only specialized cores opt out.

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.

resume_settle and state_confirm_wait override RESUME_LOAD_SETTLE and STATE_CONFIRM_WAIT for cores that are slower than the defaults assume, such as PPSSPP's multi-megabyte state files.

core_options pins libretro core option keys to values on every launch, via _write_core_options; for cores that default to writing a game's save into its own content file or disk image (PUAE) rather than SAVE_DIR, so the ROM library never gets a save mixed into it. It also pins the machine a shared core would otherwise guess from the content path (PUAE's puae_model for CDTV).

core_option_seeds pins a core option key the same way, but only the first time it is missing from CORE_OPTIONS_CFG; once seeded, whatever value is already on disk wins, including a value the user changed from RetroArch's own Quick Menu, since _write_broker_cfg makes that file the one every core's option changes round-trip through. _resolve_core_options computes the seed, and a key also present in core_options always keeps the pinned value instead. Used for VICE and Hatari's floppy/cartridge write protection, which have no write-redirect option like PUAE's: seeding it on protects the ROM library by default without permanently blocking a player who wants in-game floppy saves back.

save_links maps a SYSTEM_DIR-relative path to a SAVE_DIR-relative one, for a core that keeps a fixed-path save file outside every save_subtree (NeoCD's backup RAM); _ensure_save_links symlinks the source at the destination so the save archive's existing per-game isolation reaches it.

alternates names other cores vetted for the platform, keyed by core name, for an operator's core: override to pick. An alternate takes only the platform's extensions; it never inherits a core-owned field from the default (the fields in retroarch_cores.CORE_OWNED_FIELDS), since those belong to the core that set them, not to the platform.

extra_extensions lists extensions a core handles that core-info's supported_extensions omits, so the offline check that extensions is a subset of what the core supports (plus this list) still passes.

untested marks a platform whose default core nobody has booted a real game on yet. It still launches; the cores route reports the default unverified, the docs tier table tags it, and every launch logs a warning. It belongs to the platform rather than the core, so an alternate may not carry it.

attributeRA_ARCHIVE_EXTS
= ('.zip', '.7z')

Archives RetroArch extracts itself before handing a core the content inside.

attributeARCHIVE_LIST_TIMEOUT
= 30.0

Seconds 7z l gets to list a ROM archive for an untested core's extension check.

attributeMSU1_CACHE_DIR
= Path(os.environ.get('RETROARCH_MSU1_CACHE_DIR', str(RA_DATA_DIR / 'msu1')))

Where zipped MSU-1 games are extracted and booted from (env RETROARCH_MSU1_CACHE_DIR).

Defaults to msu1 under RA_DATA_DIR, outside the states and saves subtrees the save archive carries.

attributeMSU1_CACHE_MAX_GB
= float(os.environ.get('RETROARCH_MSU1_CACHE_MAX_GB', '20'))

Cap on the MSU-1 extraction cache in GB (env RETROARCH_MSU1_CACHE_MAX_GB, default 20).

The least recently booted games are evicted to make room for a new one.

attributeCORE_INFO_REFRESH
= 'core_info.refresh'

Makes RetroArch rebuild core_info.cache, which otherwise ignores a rewritten .info.

attributeMSU1_EXTRACT_TIMEOUT
= 1800.0

Seconds 7z gets to extract an MSU-1 archive, which can run to most of a gigabyte.