webstation-broker
REST API

Launch a game

POST /api/session/activate, with save data to restore, a state to resume, the desktop session, and disc swaps.

Launch a game

curl -X POST http://localhost:3000/streaming/api/session/activate \
  -H 'X-Broker-Secret: <shared secret>' \
  -H 'Content-Type: application/json' \
  -d '{
    "user": { "id": 1, "username": "ryan", "display_name": "Ryan" },
    "emulator": "pcsx2",
    "rom": { "name": "Soul Calibur 2", "platform": "ps2", "path": "/romm/ps2/SC2.iso" }
  }'

You get back {"status": "launching", "session_id": ..., "url": "/streaming/?token=<controller token>"}. Open https://<your RomM address>/streaming/?token=... in a browser (through your reverse proxy) to land in the room, where the game is already streaming. If a session is already active, you get back an HTTP error, 409, instead.

A RetroArch launch also answers with core, the libretro core it booted, core_tier, that core's tier, and library_name, the folder RetroArch sorts that core's saves and states into. Every RetroArch client files the core's saves and states under that folder name, so RomM files the session's there too. For an untested or blocked core, the name is a guess: the folder the restored archive's manifest recorded for that core, else the core-info catalog's name. RetroArch can turn out to write somewhere else; the state GET's X-State-Library header names the folder each state actually came from.

rom.path can point to a single file or to a whole game folder. When it's a folder, the broker automatically picks the best disc image to boot: it prefers the lowest disc number first, then a fixed format order, such as chd over iso, and so on. Either way, the path has to resolve inside ROM_ROOT, or activate answers with 400.

The payload fields:

FieldRequiredPurpose
emulatoryesThe emulator's name, from the Supported emulators list.
romunless the emulator is desktoppath is the container path (the file's location inside the container, not on your host machine). id, name and platform come back in the exit upload, and for retroarch, platform picks the core (the plugin that lets RetroArch emulate that particular system). The optional language is the rom's own language, for an emulator whose games ship several of them in one folder: ScummVM sets up one entry, called a target, per language it detects, and that target is what actually boots. Without language, a multilingual game starts in whichever language happens to sort first. A code the broker doesn't recognise is ignored rather than failing the launch. The optional title_id, save_target and save_target_layout help an emulator place a declared import. The optional core (RetroArch only) boots that libretro core instead of the platform's default, and must match ^[a-z0-9_]+$ or the launch fails with a 422. experimental_cores: true allows a core the broker lists as blocked. See Choosing a core.
gui_languagenoThe player's own interface language, not the rom's. If an emulator has a translated interface, it follows this setting (ScummVM writes it into scummvm.ini, which is also where the broker reads the keyboard shortcuts it uses to drive ScummVM's in-game menu automatically). A multilingual game folder falls back to gui_language when rom.language is missing: RomM rarely knows a game's language, so the player's own setting is the only clue left. This is sent for the desktop session too, even though it has no rom at all. An unrecognised code is ignored.
usernoid, username and display_name for the controller (the person launching the game, who gets full control by default). If they join or rejoin, the broker matches them by id first, then by username.
session_idnoAn id you supply, cleaned up so it's safe to use in a filename. The broker generates one itself if you leave this out.
savenoSee below.
callbacknobase_url and token for the exit upload; see Exit.
multiplayernoSet once on RomM's launch screen. It decides whether RomM advertises the session so others can join, and whether the room shows its chat, webcam and mic controls while the host is playing alone.

Launch a game with save data

Add a save object to restore save data. archive is a zip file restored before the game launches, and resume_slot loads a save state once the emulator is up and running. Both are optional, and you can use either one on its own. As with the state routes, resume_slot doesn't load whatever number you send: it always loads from the emulator's own working slot, so any value you give ends up loading whatever that slot currently holds.

Most emulators clear their working slot at the start of activate, before the archive is restored: PCSX2, PPSSPP, Dolphin, DuckStation, Flycast, RPCS3 and RetroArch all do this. A save-state file is named after the game it was taken from, which says which game it belongs to, but never which session created it, so a leftover from the last person to play that same game can't be told apart from your own. RomM already keeps a copy of those states, and clearing them here is what stops the last player's save from being served as yours. Both the archive restore and the later resume push happen after this clearing step, so neither one is affected by it.

curl -X POST http://localhost:3000/streaming/api/session/activate \
  -H 'X-Broker-Secret: <shared secret>' \
  -H 'Content-Type: application/json' \
  -d '{
    "user": { "id": 1, "username": "ryan", "display_name": "Ryan" },
    "emulator": "pcsx2",
    "rom": { "name": "Soul Calibur 2", "platform": "ps2", "path": "/romm/ps2/SC2.iso" },
    "save": { "archive": "/config/broker-imports/4471.zip", "resume_slot": 10 }
  }'

The archive path is a container path. RomM gets one by uploading the zip through the import route first, then using the path that route sends back.

Setting save.memory_card_synced: true tells the broker that the whole memory card is being synced separately, through the memory card routes. The card then travels as its own image, so it's left out of both this restore and the later exit dump.

A resume state can also show up after activate has already answered: the state-file PUT needs a session that's already live, so RomM sends its chosen state once the game has started booting, and the broker's deferred resume load waits for it.

An archive can also carry saves the broker did not write, declared under .import/; see Declared imports. Each is checked before the working slot is cleared, and a refused import leaves the slot as it was.

Launch the desktop

Setting emulator to "desktop" streams the full webstation desktop, so you can configure emulators the normal way, through their own interface. This mode takes no rom or save. Everything else works the same: the same room, the same exit teardown.

curl -X POST http://localhost:3000/streaming/api/session/activate \
  -H 'X-Broker-Secret: <shared secret>' \
  -H 'Content-Type: application/json' \
  -d '{ "emulator": "desktop", "user": { "id": 1, "username": "ryan", "display_name": "Ryan" } }'

Swap discs

Some emulators (currently RetroArch) report supports_disc_swap, meaning they can change the mounted disc without restarting the game:

curl -X POST http://localhost:3000/streaming/api/session/swap-disc \
  -H 'X-Broker-Secret: <shared secret>' \
  -H 'Content-Type: application/json' \
  -d '{ "path": "/romm/psx/Final Fantasy VII (Disc 2).chd" }'

path is checked against ROM_ROOT the same way activate checks its rom path. Returns {"status": "ok", "path": "<path to the disc>"}. You get 400 if the running emulator has no disc tray to swap into, 404 if the disc path doesn't exist, 409 if there's no active session, and 502 if the emulator refused the swap. Disc swaps can't happen at the same time as each other, or at the same time as a deferred resume load that's already running. A swap only counts as finished once the core confirms it's running a game again. On RetroArch, a swap also makes sure the emulator's menu is closed first, since an open menu drops the tray commands. See RetroArch for how.

What activate does

Checks the shared secret and makes sure no session is already active (answers 409 if one is), looks up the emulator by name (answers 422 if it's not recognised), and works out which file under ROM_ROOT should actually boot. A rom path that doesn't exist answers 404. A folder with nothing bootable in it, or a missing rom for an emulator that needs one, answers 422.

Shuts down any emulator left running by an earlier broker process, using the process ID (PID) it recorded at the time, and clears the emulator's working state slot of anything left over from the previous session.

Restores save.archive, if one was sent, into the emulator's save folders, but never rolls back a file that's newer on disk than what's in the archive. A file in the archive that falls outside those folders refuses the whole restore, as do an encrypted file, one with a compression method the broker cannot read, one with an invalid timestamp, and one whose data is corrupt. The broker reads every file it is about to restore in full before step 2 clears anything, so a refused activate leaves the slot as it was: an archive without imports answers 422 save restore failed: <message>, and one with imports lists the file as unrecognised_layout, unsafe_path or unreadable_member. A corrupt file refuses the restore even when a newer copy on disk means it would not have been written. Only a synced memory card sitting in an older archive gets skipped.

A save.archive path that doesn't exist answers 404. If checking the imports against the emulator crashes outright, activate answers 500 with {"error": "import_preflight_failed"} instead of launching.

Launches the emulator in its own process group, with its output captured to its log file, and records its PID. It also mints the controller token and pushes the updated token map to selkies (the video/input streaming layer), so the stream will accept input from the controller.

If you sent resume_slot, a background watchdog waits for the game to report it's running, gives it a moment to settle, then loads the state. Answers right away with the session id and the room URL, without waiting for any of that to finish. If the emulator comes up but never actually reaches a running game, the status route reports boot_failed.

On this page