webstation-broker
Emulators

Supported emulators

Every launcher the broker registers, what it can do, and the details of how each one is driven.

An activate request picks its emulator with the emulator field. Each emulator has its own launcher in the broker (in Python terms, a subclass of the broker's shared Emulator code). A launcher knows how to start the game, where that emulator keeps its save data, and whether it supports save states: saving or loading your exact progress on demand while the game is still running, not just when you quit. If you haven't got the container itself running yet, see the docker-compose example and wiring it into RomM.

emulatorSystemSave statesDisc swapSave subtrees shipped in the archive
retroarchanything with a libretro coreyes, unless the platform's core opts outyesstates/, saves/ under RETROARCH_DATA_DIR
pcsx2PlayStation 2yesnomemcards/, sstates/
duckstationPlayStationno (a resume state is written at exit)nomemcards/, savestates/
dolphinGameCube, WiiyesnoStateSaves/, GC/, Wii/
ppssppPSPyesnoSAVEDATA/, PPSSPP_STATE/ under PSP/
cemuWii Unonousr/save/ under the mlc01 tree
azahar3DSnonothe SD title/ and extdata/ trees and the NAND extdata/ and sysdata/ trees
edenSwitchnononand/user/save/ plus the profile store under nand/system/save/
shadps4PlayStation 4nonohome/1000/savedata/
rpcs3PlayStation 3no (a resume state is written at exit)nothe save directories under dev_hdd0/
xemuXboxnonoextracted from the HDD image into a staging directory
xeniaXbox 360nonocontent/ under XENIA_DATA_DIR
scummvmLucasArts and Sierra era adventure gamesyes, over the Global Main Menunosaves/ under SCUMMVM_DATA_DIR
flycastSega Dreamcastno (a resume state is written at exit)nothe flat $XDG_DATA_HOME/flycast (VMU saves and the savestate)
desktopthe webstation desktop itselfnononone

The "Save states" column above is what the broker's status response reports as supports_states. When it's off, the emulator can't save or load state while the game is running, and the save-state routes return 400 (an error code meaning the request is rejected). DuckStation, Flycast and RPCS3 are worth calling out here: supports_states is off for all three, but each one writes a state when it exits cleanly and can boot straight into that state on the next launch, so resume_slot still matters for them even though live saving and loading doesn't work. DuckStation and RPCS3 also hand that exit state out through the state-file GET once the exit has confirmed it, so RomM can keep it in its state library; Flycast doesn't yet. For every other "no" in the table, resume_slot really is ignored and the game's own save data is all that's kept. Each emulator's exact save paths are listed as the save_subtrees attribute of its class in the Python reference.

RetroArch, Flycast, DuckStation, PPSSPP, Dolphin, PCSX2, RPCS3, ScummVM, Cemu, Azahar, Eden, shadPS4, xemu and Xenia also take declared imports: save files made outside the broker, such as another emulator's, placed into the session at activate. What each emulator takes lists the files each one accepts.

RetroArch

emulator: "retroarch" is the general-purpose launcher. RetroArch itself is a front end that can run many different emulator engines, called cores, so one RetroArch launcher here covers many systems. The rom.platform field picks which core to use (ngc and wii both map to dolphin, plus snes, n64, dc, saturn, psp, nds, 3ds, arcade and genesis). The RetroArch core manifest lists the full platform-to-core mapping and what BIOS, firmware, or key file each one needs before it'll boot. If a core is missing, the broker downloads it from the libretro buildbot (libretro's server that hosts precompiled cores) into your own libretro_directory, and puts the core's .info file beside it. A core without its info file crashes RetroArch mid-session when the broker checks its status (GET_STATUS): a segfault, a crash from the program touching memory it isn't allowed to. So the broker's settings layer points RetroArch's libretro_directory and libretro_info_path at that same folder for the session, whatever your retroarch.cfg says. The broker installs a missing .info and leaves an existing one alone unless it has no systemid value, which crashes GET_STATUS the same way (NeoCD, FreeChaF and EmuSCV only gained one in August 2026). Then the broker replaces it with its catalog's copy when that has a systemid, and otherwise adds systemid = "<core>" to the file, logging a warning either way. A few dozen of libretro's own .info files have no systemid, so a core installed from one gets the same line. After writing an .info it also drops a core_info.refresh file there, so RetroArch rebuilds its core_info.cache on the next start. The broker never edits your RetroArch config directly; it only layers extra settings on top with --appendconfig. That layer also turns off config_save_on_exit. Left on (RetroArch's default), a clean quit writes the merged config, broker settings included, back over your retroarch.cfg. Earlier broker versions left it on and could leave keys such as savestate_directory = "~/.retroarch/states" or core_options_path = "~/.retroarch/broker-core-options.cfg" in that file. They don't affect streamed sessions, but if you also run RetroArch on the desktop, check your config for them.

Save states work, but RetroArch has no direct "save to slot number n" command. Instead, the broker steps the slot counter all the way down to -1 (the floor) and back up to RETROARCH_STATE_SLOT (default 0), to make sure it lands on the right slot. This happens once per launch, and again only if a save shows up in a different slot, which means the player changed slots with their own hotkeys. The first save of a session takes about 3 seconds; later saves take under 1 second. Loading uses LOAD_STATE_SLOT, which can jump straight to a slot number, so it doesn't need the same trick. Two settings control the timing: RETROARCH_SLOT_STEP_DELAY (0.1 seconds between presses, by default) and RETROARCH_SLOT_HOME_STEPS (24 presses to reach the floor, by default).

Setting RETROARCH_STATE_SLOT to -1 makes the auto slot the working slot: the presses stop on the floor, saves write <content>.state.auto, and loads send LOAD_STATE_SLOT -1. RetroArch's own auto save and auto load stay off either way, so only the broker writes or reads that file.

RetroArch's menu ignores stdin save commands while it's open, so a save sent with the Quick Menu up writes nothing. When a save writes nothing to any slot, the broker sends MENU_TOGGLE to close the menu, parks the slot again (the menu has its own State Slot setting), and retries. If the retry writes nothing either, it toggles the menu back, so a menu the player opened stays open and one the broker opened is closed again. A retry that lands is followed by one more save: if that one writes nothing, the first save was only slow and the toggle opened a menu, so it's toggled closed. A save that lands in a different slot is a slot change, not a menu, and only gets the re-park. RETROARCH_MENU_SETTLE (0.5 seconds, by default) is the pause after the toggle.

The menu can also be opened on purpose. Players on a phone have no F1 key and often no stick buttons, so the room shows the host an Emulator menu button that sends MENU_TOGGLE (the menu route is what it calls). For a physical gamepad, the layer sets RetroArch's menu combo to L3 + R3 (both sticks pressed in), but only when your retroarch.cfg has no combo of its own. RETROARCH_MENU_COMBO changes which combo that is, and 0 turns it off. The layer also hides the menu entries that don't belong in a streamed session: Configuration File (which loads, saves and resets whole configs), the core, game and content-directory override saves, Close Content, Load Content, Load Core, Load Disc, Dump Disc, the History, Playlists and Explore tabs, Restart RetroArch and Quit RetroArch. Add to Favorites, the Favorites tab, the Online Updater and Download a Core are hidden as well, since what they write outlives the session: a favorite is a game the next host could launch in place of their own, and the updater replaces the cores every later session runs. It hides four Settings pages too: Configuration (where Save Configuration on Quit lives), Directory and Saving (both can move states away from where the broker looks for them), and User Interface (which could un-hide all of this). Save states, shaders, cheats, core options, controls and Disc Control stay visible. A remap file saved under Controls, or a shader preset saved under Shaders, goes into the container's RetroArch config and is loaded again in later sessions of that core or game, whoever hosts them, while auto_remaps_enable and auto_shaders_enable are on (RetroArch's default). To undo one, delete it from the RetroArch config directory: remaps live under config/remaps/<core>/, shader presets under config/<core>/.

Only the player on gamepad 1 can steer the menu, unless your retroarch.cfg turns on all_users_control_menu. A keyboard works too, since its keys belong to player 1. Opening it is different: during play, RetroArch reads the combo from every gamepad, so any player can open the menu, and with RetroArch's default menu_pause_libretro the game pauses for everyone while it's open. Once it's open only gamepad 1 is read, so gamepad 1, the keyboard or the host's button has to close it.

The menu drops disc commands the same way it drops saves, and RetroArch's status still reports the game as playing. So before a disc swap moves the tray, the broker makes a check save: if it lands, the menu is closed; if it writes nothing, the broker closes the menu and checks again, this time with two saves in a row. The second one is there for a core that was only slow: its first save can land late, after the toggle has opened a menu, and look like the menu closing. When the checks don't land, it toggles the menu back and refuses the swap rather than record a disc change that never happened. The check saves are undone afterwards, file bytes and timestamps included, so the exit upload never sees them. If a file can't be put back, the broker logs an error naming it, and that file goes to RomM with the session's other states. Loading a state doesn't need any of this: LOAD_STATE_SLOT works with the menu open.

Not every core can save a state (some can't turn their memory into a save file, i.e. "serialize" it). A platform whose core can't do this can be marked "savestate": false in the table. When that's set, supports_states reports false for that platform, the save-state routes return 400, resume_slot is ignored, and exit saves no state. RetroArch also empties its working slot when a game activates, the same as PCSX2 does below.

For multi-disc games, rom.path can point at an .m3u playlist file, or a folder that contains one; on disc-based platforms the broker prefers the playlist when one exists. While the game is running, you can switch discs through the swap-disc route.

A declared import brings in one .srm on a platform whose core loads one, Wii title folders on wii, or Azahar's SD card and NAND folders on 3ds. ngc still refuses a save: its GCI shape has not been verified, so import through dolphin instead. See RetroArch cores.

The full list matching each platform to its core lives in retroarch_platforms.json, next to the launcher's code. To pin exactly where one specific core downloads from, without editing that file, set RETROARCH_CORE_URL_<CORE>.

PCSX2

The broker talks to PCSX2 over its PINE socket ($XDG_RUNTIME_DIR/pcsx2.sock), PCSX2's own interface for letting outside programs send it commands, for saving, loading, checking status, and reading the game's serial number. PCSX2 saves into slot 10, which is PCSX2's own built-in autosave slot. State files (.p2s) are named after the disc they came from, and the game serial can only be read off the disc that's currently running, so a leftover state file from an earlier session can't be told apart from the current game's just by looking at it. PCSX2 therefore clears its working slot at the very start of activation, before the save archive is restored: RomM already keeps its own copies of these states, and clearing the slot first is what stops an old player's save from being served as if it belonged to the new session.

The Slot 1 memory card is a folder-based card the broker manages itself (PCSX2_SLOT1_CARD, default romm-slot1). If save.memory_card_synced is set, the whole memory card is handled through the memory card routes instead of being bundled inside the regular save archive.

A declared import can bring in one folder memory card, which becomes the Slot 1 card, as long as the archive carries no card of its own. States still come in through the state-file PUT, which refuses a state named for another game's serial once RomM has sent the game's title_id.

DuckStation

DuckStation has no mid-session save states: it doesn't give the broker anything to hook into while a game is running, so the save-state routes return 400. What it does have is a graceful shutdown: when it closes cleanly, it writes a resume state, and the broker reports that file as the exit save state and serves it through the state-file GET until the next activate. Getting that clean shutdown takes longer than for other emulators, which is why DUCKSTATION_STOP_WAIT, the time the broker waits after asking DuckStation to quit (a SIGTERM signal, the standard request a program can ignore or handle gracefully) before forcing it closed, is the longest of any emulator here. Memory cards and that resume state both live under the data root that the save archive carries.

Memory card 1 is one shared card, memcards/shared_card_1.mcd, that every game uses. DuckStation's own default names the card after each game, which the broker can't know before the disc boots, so the broker pins the shared card in DuckStation's settings on every launch. An archive saved before that pin holds a per-game card (<title>_1.mcd) instead. While the shared card doesn't exist, a launch copies the newest per-game card to it. It's a copy, never a move, so the per-game card stays on disk and in the archive. If the copy fails, the launch fails rather than boot the game on a blank card.

A declared import can bring in a raw memory card, which becomes the shared card, or a resume state, which needs resume_slot and which the game boots straight into.

Dolphin

Save and load are done by sending Dolphin the same keyboard shortcuts a player would use, through xdotool, a tool that lets the broker simulate key presses inside the container: Shift+Fn saves slot n, and Fn loads it. The broker waits for Dolphin's game window to appear before sending these, and confirms a save actually happened by watching the StateSaves/ folder for the new file. The video backend is left to Dolphin's own config, so a backend picked in Dolphin's settings survives the next launch. For GameCube, memory card Slot A is set to a folder-based card device, so the card is a normal directory the save archive can carry along.

A declared import can bring in GameCube .gci saves, which go onto the Slot A card, Wii title saves, which go into the NAND, or one state, which needs resume_slot. The state has to open with the launched game's id, and the state-file PUT holds a pushed state to the same game.

PPSSPP

Like Dolphin, PPSSPP is driven with keyboard hotkeys, and it always saves to slot 1. Save data and save states both live under PSP/ in the config folder.

For PSP games, the RetroArch ppsspp core is generally the better choice. It's the same emulator engine underneath, but running it through RetroArch gets you RetroArch's controller handling and its proper save-state interface, instead of the standalone app's hotkeys-into-its-own-menu approach and its shakier controller support. PSP games aren't demanding enough for RetroArch's extra overhead to matter. The standalone PPSSPP launcher is still here and supported, for anyone who prefers PPSSPP's own interface and settings.

A declared import can bring in PSP save folders, each with its PARAM.SFO, or one state with its screenshot, which needs resume_slot. The RetroArch ppsspp core takes no imported PSP save: RetroArch refuses one and names ppsspp in the refusal's suggest_emulator.

With PPSSPP_CACHE_ENABLED=true, a PSP image stored as a .7z, .zip, or .rar archive boots too. PPSSPP can't read one directly, so the first launch extracts it into the extraction cache and later launches reuse that copy. rom.path can name the archive itself, or a folder that holds exactly one archive and no loose image; an archive in a subfolder beside it, such as extras, doesn't count. A folder with several archives side by side is refused, since there's no telling which one is the game, and so is an archive whose image sits more than one folder down. Save data is keyed by the game's serial, not its file path, so zipping a game you already have saves for keeps them.

Cemu

Cemu has no save states; mlc01/usr/save/ is where its save data lives. Cemu identifies controllers by a GUID, a unique ID string that SDL (the input library it uses) generates for each device. So the launcher works out what GUID the room's virtual gamepad will report, based on CEMU_PAD_NAME, and writes a matching controller profile before each launch (CEMU_PAD_UUIDS can override that derived list if you need to set the GUIDs yourself). Cemu doesn't respond to a SIGTERM shutdown request at all, so CEMU_STOP_WAIT only covers the time the broker allows for tearing down Cemu's process group, not for Cemu to shut itself down cleanly.

A declared import can bring in a Wii U save for the game being launched. It is placed under Cemu's own account, in place of the account the save was made under.

Azahar

Azahar has no save states and no way for the broker to send it commands while it runs, so quitting it means killing the process outright: only whatever the game itself already wrote to disk survives. Single-game launches also start it windowed instead of fullscreen, for a reason covered in Azahar setup. For 3DS games, the RetroArch core is the better option, and it's what a 3ds launch uses by default.

A declared import can bring in the save data of a 3DS title from an unencrypted SD card or NAND folder.

Eden

Eden has no save states; nand/user/save/ is where saves persist. A running game takes longer than the broker's usual shutdown grace period to close, which is why Eden gets its own EDEN_STOP_WAIT setting.

A declared import can bring in Switch save folders from a NAND export. An account save has to come with the profile store it needs.

shadPS4

emulator: "shadps4" launches the shadPS4 PlayStation 4 emulator. shadPS4 has no save states (resume_slot is ignored); the game's own save data, under home/1000/savedata/, is what persists. The broker picks the shadPS4 binary from $HOME/.local/share/shadPS4QtLauncher/versions: it uses the Pre-release/ build if one is present (a pre-release always wins over a numbered release), otherwise the newest release folder by version number (vX.Y.Z - ... - <date>/, each holding a Shadps4-sdl.AppImage file). You can override that search with SHADPS4_BIN (an exact path to the binary), SHADPS4_VERSIONS_DIR, or SHADPS4_BIN_NAME. The broker controls shadPS4 through its own IPC, a channel for sending it commands while it runs, over stdin (SHADPS4_ENABLE_IPC=true): a RUN then START command boots the game headlessly, and on exit the broker sends STOP, which triggers shadPS4's own quit event (the same thing that happens when its window is closed normally), for a clean shutdown before the changed save data is written to disk. shadPS4 doesn't respond to SIGTERM at all, so SIGTERM is only used as a fallback if STOP hasn't finished within SHADPS4_STOP_WAIT (default 20 seconds).

rom.path can be a game folder that contains eboot.bin, a bare eboot.bin file, or a .zar archive. A .pkg file, or a .7z/.zip/.rar archive containing one, only boots if SHADPS4_CACHE_ENABLED is turned on, since it first has to be unpacked into a cache folder. With the cache turned off, those formats are rejected up front instead of failing partway through, and they're left off the emulator's list of supported file types entirely. See shadPS4 setup for sizing the cache and the disk space an archive needs.

A game folder with no eboot.bin is searched (its top level and one subfolder down) for a .pkg or archive, which then boots as if RomM had pointed at it directly. A .pkg whose header says it's the base game is picked first, and one whose header says it's a patch or DLC is picked last. Between those (archives, and a .pkg with no readable header), anything with update, patch or dlc in its file or subfolder name loses, since a PS4 patch can be bigger than the game it patches. After that a .pkg wins over an archive, and when there are several the largest is picked.

A declared import can bring in PS4 save folders. They are placed under the default user, whoever made them.

RPCS3

Like DuckStation and Flycast, RPCS3 has no live save states: nothing here lets a game be saved or loaded while it keeps running. What it has instead is an exit-time save. Sending RPCS3 its own save-state hotkey (RPCS3_SAVE_STATE_KEY, ctrl+alt+1 by default) always closes the game once the state finishes writing, so the broker triggers that hotkey and waits for the process to end rather than keeping it open. A resume works by pointing the next launch straight at the newest saved state instead of the game itself: RPCS3 tells a save state apart from a normal boot target by its file contents, not its extension, so booting into one works the same way booting a ROM does. States are kept one per title, so resume_slot always finds the right one. The state an exit confirms is served through the state-file GET until the next activate, the same way DuckStation's is. The game's own save data, under dev_hdd0/home/, persists separately from all of this.

A declared import can bring in PS3 save folders, each with its PARAM.SFO, or one savestate, which needs resume_slot and which the game boots straight into. The savestate has to belong to the game being launched, and a rom the broker can read no title id from, a bare .iso, takes none.

rom.path can be a PKG file, which the launcher installs first (RPCS3_INSTALL_TIMEOUT limits how long decryption is allowed to take), an already-extracted game folder, or a disc image.

A .7z/.zip/.rar archive of a game folder only boots if RPCS3_CACHE_ENABLED is turned on, since it has to be extracted first. With the cache off, that format is rejected up front rather than accepted and failing partway through, and it's left off the emulator's list of supported file types. A PKG file isn't affected either way, since its installer is separate from the cache. See RPCS3 setup for sizing the cache and the disk space an archive needs.

xemu

xemu has no save states. It keeps its saves inside a single virtual hard drive image, in the qcow2 format (a type of virtual disk file), rather than as separate files. After a clean shutdown, the launcher pulls the saves out of that image (working through the plain raw image the container prepares the first time it starts) and puts them back before the next launch; the save directories are matched using whatever letter-casing the virtual disk itself uses. The renderer is set in xemu.toml before each launch (XEMU_RENDERER; set it to KEEP to leave your own choice alone), and XEMU_SOFTWARE_GL forces rendering on the CPU instead of the GPU, for hosts where neither GPU rendering path works.

A declared import can bring in the UDATA and TDATA folders of the game being launched. They reach the hard drive image at launch.

Xenia

emulator: "xenia" launches the Xenia Edge Xbox 360 emulator. Xenia has no save states (resume_slot is ignored) and no way for the broker to send it commands at all while it's running, no IPC, socket, or stdin channel of any kind. So the game's own save data is what persists, and exiting means sending a plain SIGTERM, which Xenia treats as an ordinary kill. That's safe here because the game writes its saves straight through to the host's files as it plays, so nothing sits in memory waiting to be lost.

The save archive is the entire content/ folder tree under XENIA_DATA_DIR. Xbox 360 save paths are tied to the player's profile ID (called an XUID), and the profile data itself lives in that same tree, so both travel together and still line up correctly when restored into a fresh container.

rom.path can be an XISO disc image (.iso), a bare .xex file, an extracted dump (a folder containing default.xex), or an XBLA / Games on Demand title folder. For that last case, the broker walks the same folder layout the console itself uses ([Content/<XUID>/]<TITLE_ID>/000D0000 or 00007000/<package>) and boots whichever file is a genuine game package in Xbox 360's own STFS format (Microsoft's package format for Xbox Live content), confirmed by checking the start of the file rather than its name. That way DLC, title updates, and the extra .data file that sits beside a Games on Demand package are correctly skipped.

A declared import can bring in Xbox 360 saves for the game being launched. They are placed under the profile that is signed in, which the broker reads from Xenia's config, so a profile has to exist first.

Flycast

Flycast has no control socket and doesn't respond to SIGTERM either, so the only way to shut it down cleanly is a graceful window-close request: the broker switches focus to Flycast's own window and sends Alt+F4, the same shortcut a player would use. That reaches Flycast's own quit handling, and it's the only moment a save state gets written (FLYCAST_STOP_WAIT gives it that window of time before the broker escalates to a SIGTERM, which skips the save entirely). Like DuckStation, there's no saving or loading mid-session: a launch resumes from whatever state was written the last time it exited cleanly, and the current session's state is written exactly once, on exit.

The save state is always a single, predictably-named file, <rom>.state, sitting directly in $XDG_DATA_HOME/flycast (no subfolders). VMU (Dreamcast memory card) saves live loose in that same folder and travel with it as one save subtree, since nothing separates them from the save state, or from a leftover first-run setup file. A RetroArch core also covers the Dreamcast (dc uses it by default), for anyone who'd rather use RetroArch's shared save-state menu and hotkeys instead.

A declared import can bring in VMU images, the console's system flash (dc_nvmem.bin), or a save state. An imported state needs resume_slot. It is renamed after the game being launched, and the game boots straight into it, as it does with the state Flycast writes on exit. A RetroArch .srm isn't a VMU image, so Flycast refuses one.

ScummVM

For ScummVM, rom.path points at a folder of game files, not a disc image. ScummVM works out what game a folder holds by scanning its contents, so launching runs scummvm --add --path=<folder> and then reads back the target name it generated (something like monkey or gob1-cd-fr) from scummvm.ini. A target is ScummVM's own internal name for a registered game. It's the target, not the folder path, that actually boots the game and that every save file is named after. If ScummVM adds nothing for a folder, that means it found nothing bootable there, but --add still reports success even when it added nothing, so its exit code alone can't tell you whether it worked. If rom.path points at a single file instead, a .scummvm marker file, for example, ScummVM registers the folder that file sits in.

A game can also be stored as a single .7z, .zip, or .rar archive, either named directly by rom.path or sitting alone in the game's folder. ScummVM can't detect a game inside an archive, so the first launch extracts it into the extraction cache and registers the extracted folder instead. A single wrapper folder inside the archive (Monkey Island/, say) is walked into, and macOS __MACOSX clutter is ignored. Later launches reuse the same extraction and the same target, so saves keep their names. Replacing the archive with a different file extracts it again, under the same target. A second archive of the same game is refused, the same way a second folder copy is, so two copies never share one set of saves. An archive with no game files in it is refused before anything is extracted.

A folder holding a game in multiple languages registers one target per language ScummVM detects (gob1-cd-fr, gob1-cd-de), and it's the target, not a command-line flag, that decides which language's files actually get loaded: passing --language on its own does not switch a launch to a different variant. The language field on the activate payload is what picks between the targets, and the broker also passes it on the command line, so the running game agrees with the target it was launched from. Language codes get translated into ScummVM's own spellings first (pt-br becomes br, jp becomes ja, zh-hans becomes cn); if the exact one isn't available, a close match is used instead (a us variant will answer an en request), and a code ScummVM wouldn't recognize is simply dropped rather than failing the whole launch. If a ROM entry doesn't specify a language at all, the broker falls back to gui_language, the player's own interface language setting, since RomM usually doesn't know what language a game is in, and a multilingual folder is exactly the case where that matters most. If neither is available, ScummVM just picks alphabetically, but it does so consistently, which keeps the <target>.sNN save file names stable across relaunches.

ScummVM has no control socket and no dedicated save/load hotkeys, so the broker drives its Global Main Menu directly using xdotool (the same simulated-keypress tool used for Dolphin and PPSSPP): it presses the menu key to open the menu, presses the Save or Load button's own letter hotkey, presses Down to walk the list down to the right slot, and presses Return to activate it. When saving, the first Return starts editing the slot's text description, and a second Return confirms it.

Three settings are pinned in scummvm.ini to make all of this work reliably. First, gui_saveload_chooser=list, because the default menu shows saves as a grid, and there's no way to arrow-key your way to a specific numbered slot in a grid. Second, keymap_global_MENU is bound to SCUMMVM_MENU_KEY (default F11). ScummVM's own default menu shortcut is C+F5, but a Ctrl modifier doesn't survive being simulated inside the container's Xwayland (the compatibility layer that lets this X11-based software run under Wayland) display server, so the key press arrives without its Ctrl and the menu never opens. The plain, unmodified F5 that does work is normally reserved for each game engine's own shortcuts, and some engines grab it for themselves (the gob engine, used by the Gobliiins games, opens its own panel on F5 instead, which has no Save option). Third, the Save and Load button labels follow ScummVM's own translated menu text (so ~S~ave becomes ~S~auvegarder in French, for example), matching whatever gui_language is set to. The activate payload's gui_language field pins this when the request supplies one, and otherwise leaves it as the user already set it themselves. Only that one keymap binding is changed; any other keyboard shortcut you've set yourself is left alone.

The sequence of key presses doesn't report back whether it actually worked, so the broker only counts a save as successful once it sees the slot's file actually change on disk. Some engines don't support saving while running at all; for those, ScummVM pops up a message dialog instead of a save menu, and the broker dismisses that dialog rather than leaving the game stuck behind it. Gobliiins is the clearest example: its gob engine has no save handler whatsoever, because in that game, passwords are how you track progress instead of save files. Games like this still have their own saves included in the save archive, and resuming them at boot time still works fine.

In ScummVM, saves and save states are the same thing: there's no separate save-state format, so the broker's working slot and the game's own manual saves live in the same folder and are only told apart by their file names. Slot 0 is ScummVM's own autosave slot, and ScummVM's own save menu marks it write-protected (MetaEngine::listSaves sets the flag), so the broker never writes there. Instead it uses SCUMMVM_STATE_SLOT (default 1). When a state is loaded back in, it's renamed to match the currently booted target, because ScummVM finds a save purely by name, and a multilingual folder can register a separate target per language.

A declared import can bring in <game>.NNN saves, named for a game in the rom folder, or one state, which needs resume_slot. The state is renamed onto the booted target in the working slot, like a state loaded back in.

Two more settings are pinned for the sake of the video stream, not because ScummVM itself needs them. fullscreen is pinned off, no matter how the game is launched. A ScummVM window always renders at the game's own native resolution, so a 640x480 game would otherwise show up as a tiny postage stamp in the middle of the stream. Using ScummVM's own fullscreen mode to fix that would be the wrong move: internally, going fullscreen makes SDL (the graphics and input library ScummVM is built on) grab the mouse pointer and confine it to a fixed rectangle (shouldGrab = ... || fullscreenFlags, then SDL_SetWindowMouseRect), and also request a specific display resolution. Both of those fight against a video-streaming stack that feeds in an absolute mouse position and controls the display size itself: the result is a pointer that stops tracking the stream even though clicks still register, which is a serious problem since these games are controlled almost entirely by mouse.

So instead, the desktop's window manager handles it directly. After launch, the game's window is moved to the top-left corner and resized to fill the whole display (SCUMMVM_FILL_SCREEN, on by default). SDL treats that as a normal window resize: it scales its own picture to fit and adds black bars (letterboxing) to keep the game's 4:3 shape correct, all without ever triggering the pointer-grab behavior that fullscreen mode causes. This is best-effort and purely cosmetic: if the display size can't be read, or the window never appears, the game just keeps running at its own native size instead of the launch failing outright. One thing it doesn't remove is the window's title bar, since nothing in the container image is able to (the xdotool version shipped, 3.2016, has no window-state command, and there's no wmctrl either; a labwc window rule could do it, but isn't set up). ScummVM's own window_maximized setting isn't used either, since labwc, the window manager here, leaves the window at its own size regardless, so that setting wouldn't fill anything. The broker also pins gfx_mode=surfacesdl instead of ScummVM's OpenGL renderer, because the OpenGL renderer scales mouse coordinates through a call (SDL_GL_GetDrawableSize) that only means anything inside a real OpenGL window. Against the stream's injected absolute pointer, that mismatch means clicks still land in the game, but the on-screen cursor stops moving. Finally, SDL is forced onto X11 at launch, so the broker's simulated key presses have an Xwayland window to talk to.

None of the above fixes one deeper pointer problem that isn't something this launcher alone can solve. While a game is running, it holds what's called a pointer lock: ScummVM hides the normal mouse cursor and moves (warps) it itself. Xwayland emulates that lock, but the desktop's window manager, labwc, drops the absolute mouse-position updates the browser sends while a lock is active. It still forwards relative movement, though, so the result is an in-game cursor that freezes in place while clicks still land where you click. To work around this, the container image patches selkies' own input handler (input_handler.py) so that every absolute mouse move is sent as a relative movement followed by the absolute position, and so that it reads the actual pointer position back afterward (using the python-xlib library) to correct for any warp the game just issued. Without that patch, the mouse pointer would be unusable even in windowed mode, no matter what this launcher pins.

Desktop

emulator: "desktop" streams the full webstation desktop, so you can configure emulators through their own graphical interface. It doesn't use a rom or save field, but everything else, the room interface and how a session exits, works the same as any other launcher. This is how you do the one-time setup.

On this page