webstation-broker
REST API

Save archives

Moving save archives in and out of the container, and syncing a whole memory card, over the import, export and memory-card routes.

Save archive transfer

When you launch a game, the save.archive field has to be a file path inside the container. But RomM runs as its own separate service: it only has the raw save file, not a way to write directly into the container's file system. So a save archive has to travel between RomM and the container over these endpoints, in both directions.

PUT    /streaming/api/session/imports/{name}.zip   body: raw zip
GET    /streaming/api/session/exports
GET    /streaming/api/session/exports/{name}.zip
DELETE /streaming/api/session/exports/{name}.zip

All four routes need the X-Broker-Secret header. The upload returns {"status": "stored", "name": ..., "path": ..., "size": ...}; feed that path back as save.archive on activate. The name must be a plain .zip filename with no folders in it, the uploaded data must actually be a zip file (checked by its file signature), and it can't be bigger than the same 256 MiB limit the exit dump uses.

Pulling an archive through these routes covers two cases the automatic exit upload can't: dev mode, where uploading is turned off and the archive only ever ends up on disk, and a failed upload, where the archive on disk is the only copy of the save data left. Delete each archive once RomM has stored it.

# hand a save archive to the container, then launch with it
curl -X PUT http://localhost:3000/streaming/api/session/imports/4471.zip \
  -H 'X-Broker-Secret: <shared secret>' --data-binary @4471.zip
# -> {"status": "stored", "name": "4471.zip", "path": "/config/broker-imports/4471.zip", "size": 12345}

# list, fetch and discard what exit left behind
curl http://localhost:3000/streaming/api/session/exports -H 'X-Broker-Secret: <shared secret>'
curl http://localhost:3000/streaming/api/session/exports/abc123-1723600000.zip \
  -H 'X-Broker-Secret: <shared secret>' -o abc123.zip
curl -X DELETE http://localhost:3000/streaming/api/session/exports/abc123-1723600000.zip \
  -H 'X-Broker-Secret: <shared secret>'

An upload larger than SAVE_FILE_MAX_BYTES answers 413, and a body that isn't a zip answers 422.

Members under .import/ are not restored path for path; they are placed by the emulator's own rules. See Declared imports.

What an archive holds

An archive is a zip file of the emulator's save subtrees (the specific folders inside its save data that actually hold saves and states). The paths inside the zip are relative to the emulator's save root folder: for PCSX2, that means paths like memcards/... and sstates/.... When an archive is restored, a file already on disk is never overwritten by an older copy from the archive. A file outside those folders refuses the whole restore before anything is cleared (it is listed as unrecognised_layout in an archive with imports), and so does an encrypted file, one with a compression method the broker cannot read, or one whose data is corrupt. The broker reads every file in full to check before it clears anything. Only a synced memory card in an older archive is skipped. That's what keeps an old archive from rolling a session backward. When an archive is built at exit, only the files that changed since launch go into it, so an archive holds just the changes, not a full copy of the save data.

The archive manifest

Every emulator lays its save directories out differently, and there are fourteen of them. So RomM doesn't have to keep its own table of those layouts, an archive built at exit also holds a .broker-manifest.json file at its root, naming the session and labelling each file:

{
  "version": 1,
  "created_at": 1787880008.5,
  "session": {
    "emulator": "retroarch",
    "core": "mgba",
    "platform": "gba",
    "rom_id": 5,
    "rom": "Advance Wars",
    "rom_file": "/romm/library/roms/gba/Advance Wars (USA).gba",
    "state_slot": 0
  },
  "files": [
    { "path": "states/mGBA/Advance Wars (USA).state", "kind": "state" },
    { "path": "states/mGBA/Advance Wars (USA).state.png", "kind": "state_screenshot" },
    { "path": "saves/mGBA/Advance Wars (USA).srm", "kind": "save" }
  ]
}

kind is one of state, state_screenshot, memcard or save. core is only set for a launcher that can run several different emulator cores, like RetroArch (mgba above), so a RetroArch PSP archive (core: "ppsspp") can be told apart from a standalone PPSSPP one. state_slot is null when the emulator has no save states at all. When the session placed declared imports, an imported list names the placed files, which ship whether or not the game changed them.

The manifest describes what's in the archive, it isn't save data itself: it doesn't appear in the save_dump.files list, and when an archive is restored, the manifest file is dropped instead of being written into the emulator's save data. Archives made before this manifest existed simply carry none, and they still restore exactly as they always did.

Memory cards

Some emulators use a memory card: a single file or folder holding save data, the way a real memory card would. The broker manages this file directly for PCSX2 (its Slot 1 folder card) and Dolphin (its GameCube memory card). Instead of bundling it inside every save archive, RomM can sync the whole card against the player using its own routes:

GET /streaming/api/session/memory-card?emulator=pcsx2
PUT /streaming/api/session/memory-card?emulator=pcsx2   body: raw zip of the card

GET /streaming/api/session/memory-card?emulator=dolphin&platform=ngc
PUT /streaming/api/session/memory-card?emulator=dolphin&platform=ngc   body: raw zip of the card

Both routes need the X-Broker-Secret header. The GET sends the card back as a zip file, with an X-Memory-Card-Slot: 1 header (this is always 1, not a real slot number for every emulator). A 404 response carrying an X-Memory-Card: absent header is the broker confirming the card is genuinely empty, which is what tells RomM it's safe to wipe its own copy. Every other kind of failure reads as "could not be captured", so a card RomM never managed to read is not mistaken for an empty one and deleted the next time it tries.

The PUT wipes the existing card and writes the one you send in its place. It's refused with 409 while a session is running: the emulator keeps the card file open for as long as the game runs, and replacing it underneath would corrupt it. RomM sends the card down before activate and pulls it back out after exit, so the card is only ever swapped while nothing is running. A card larger than SAVE_FILE_MAX_BYTES answers 413.

The platform parameter says which card to sync, for an emulator whose card depends on which platform it's currently running. Dolphin is the case that needs it: the same emulator handles both GameCube and Wii, but only GameCube has a physical card (Wii saves to its internal flash storage, called NAND, which has no memory-card equivalent and always travels inside the regular save archive instead), so this route requires platform=ngc to say which one you mean. Leave platform off, or send anything else, and the route answers 400, the same as it would for an emulator with no card at all. The card you get back is Dolphin's whole GC/ folder rather than one region's Card A folder, since a game library can mix regions and a sync has to cover all of them. PCSX2 needs no platform value, since its Slot 1 card doesn't depend on one.

Dolphin only reads a GameCube save that sits at <USA|EUR|JAP>/Card A/<name>.gci inside that folder, so a card pushed to Dolphin is tidied on the way in. A leading GC/ folder is dropped, and a .gci anywhere else (loose, under a bare Card A/, or under Card B/) moves to the Card A folder of the region Dolphin boots its game in, read from the last letter of its game code the same way Dolphin reads it: E, B and N are USA; J, and the Korean K, Q, T and W, are JAP; D, F, H, I, L, M, P, R, S, U and V are EUR. SRAM.raw, Dolphin's console settings file, stays at the top. A move never lands on a path another file in the zip already uses, packed or moved, so a copy already in place keeps its spot.

A .gci the broker cannot place stays where it was packed: one coded X, Y or Z, which Dolphin files by the disc's region rather than the code, or one whose spot is already taken. So does a whole card image (.raw, .mcd, .gcp), which the folder card never reads: export its saves as .gci files with Dolphin's Memory Card Manager first. The push still answers 200, because a refused push makes RomM abort the launch, and names each such file in an unread list:

{"status": "ok", "written": 2, "unread": ["Card A/01-GZLX-zelda.gci"], "slot": 1}

written counts every file laid down, unread included. For PCSX2, and for a Dolphin card that needed no help, unread is empty.

When you sync a card this way, tell activate about it with save.memory_card_synced: true. The card then travels as its own file, separate from the save archive: a restore will skip a stale card folder if an older archive still has one, and the exit dump leaves the card's subtree out entirely. A 400 from either memory-card route means the named emulator has no memory card for the given platform; a 422 means the emulator name itself isn't recognised.

On this page