@@ -1233,6 +1233,55 @@ rollback API.
12331233 devices still see the original newest save. Calling ` rollback_to_version ` again is safe and idempotent: step 1 is
12341234 already done, step 2 retries the PUT.
12351235
1236+ ## Copy a save to another slot
1237+
1238+ A per-save ** "Copy to slot…"** action (` SaveCopyService ` , ` services/saves/copies.py ` ; callable ` copy_save_to_slot ` )
1239+ takes one server save — from a named slot, an older version, or the read-only legacy no-slot bucket — and copies its
1240+ content into a target slot. The action is offered on every save row that carries a server save id: the active slot's
1241+ current save, its Previous-Versions rows, and the inactive/legacy slot panels.
1242+
1243+ Two things define the semantics:
1244+
1245+ - ** It is a copy, never a move.** The source save is never deleted (the user removes it in the RomM web app if they want
1246+ to). A ROM has many slots; this does not "move the ROM onto one slot".
1247+ - ** The target slot becomes the ROM's active/confirmed slot,** with the copied save as its current save. This is
1248+ mechanically forced: ` do_upload_save ` resolves its upload slot purely from ` save_state.active_slot `
1249+ (` _resolve_upload_slot ` ) — there is no per-call slot override — so ` active_slot ` is set to the target (` confirm_slot ` )
1250+ _ before_ the upload.
1251+
1252+ The flow is a composition of the rollback orchestration shell and the wizard-migration's make-current step (it extends
1253+ neither in place):
1254+
1255+ 1 . Validate the target name (empty/whitespace → ` invalid_slot_name ` ).
1256+ 2 . Under ` rom_lock ` , refuse an unconfigured ROM (` active_slot ` is ` None ` or the slot is unconfirmed) with
1257+ ` not_configured ` — the Copy action is only reachable on a configured ROM, and this is the defensive backstop, not a
1258+ UI-gate reliance (the #1529 lesson). ` rom_not_installed ` when there is no install record.
1259+ 3 . Multi-file (#908 ) and content-dir (#239 ) layouts are refused with ` unsupported ` (the content-dir case carries the
1260+ ` savefiles_in_content_dir ` reason), exactly as rollback refuses them — copying one component of an N-file set would
1261+ produce an incoherent save, and a content-dir layout writes where RetroArch never reads.
1262+ 4 . A ** matrix pre-flight** (` do_sync_rom_saves ` ) runs unconditionally on the _ current_ slot, protecting its dirty local
1263+ before the copy overwrites it: ` conflict_blocked ` / ` preflight_failed ` on a bad pre-flight (same shapes as rollback).
1264+ 5 . ` list_saves ` with ** no slot filter** (the chosen save may live in any slot) locates the save by id; a missing id is
1265+ ` version_deleted ` , a failed call is ` server_unreachable ` .
1266+ 6 . ** Dedup pre-check** : if the chosen save's ` content_hash ` already matches a save in the _ target_ slot, the copy is a
1267+ no-op — ` already_present{existing_id} ` (the existing twin's id) is returned with ** no** download / upload /
1268+ make-current, so nothing churns (the frontend toasts "already in slot … as ` #<id> ` " and does not refresh). It fires
1269+ only when ` content_hash ` is populated (RomM 5.0.0+); an absent hash falls through to the copy and RomM's own
1270+ server-side dedup. This is what stops a repeat-copy of already-present content from re-pointing the tracked save.
1271+ 7 . ` SaveCopyService._copy_save_to_slot_io ` then: ` confirm_slot(target) ` (creates the slot if new, switches active +
1272+ confirms) → ` do_download_save ` (the chosen content onto the canonical local file, quarantining the current local file
1273+ first, #965 ) → ` do_upload_save(server_save=None, overwrite=False) ` — a POST into the now-active target slot. A
1274+ ** 409** is ` target_slot_busy ` (the target has newer changes from another device — sync it first, then retry); any
1275+ other upload error is ` copy_failed ` .
1276+
1277+ The status union mirrors ` RollbackStatus ` :
1278+ `ok | not_configured | invalid_slot_name | rom_not_installed | version_deleted | unsupported{reason?} |
1279+ server_unreachable{message} | conflict_blocked{conflicts} | preflight_failed{errors} | target_slot_busy{message} |
1280+ already_present{existing_id} | copy_failed{message}`.
1281+ On ` ok ` the frontend dispatches ` romm_data_changed ` so the parent re-fetches — refreshing both the source and the target
1282+ slot views. The source slot is excluded from the target picker (a same-slot copy is just a rollback) and the legacy ` "" `
1283+ bucket is excluded as a target (it stays a read-only source).
1284+
12361285## RomM Save Sync API Behaviour
12371286
12381287The plugin depends on several RomM v4.8.1 behaviours that are not obvious from the OpenAPI schema and were discovered
@@ -1802,11 +1851,13 @@ RetroArch save states (`<states_path>/{system}/`, where `<states_path>` comes fr
18021851` paths.states_path ` ) are not synced. Only SRAM saves (` .srm ` ) are handled. Save states are large,
18031852emulator-version-specific, and not portable between different RetroArch core versions.
18041853
1805- ### Save slot migration between slots not yet implemented
1854+ ### Copying a save into another slot
18061855
1807- Moving saves between slots (copy from slot A to slot B) is not supported. Users can delete named slots (which removes
1808- all saves in the slot from the server) and create new ones, but there is no "move saves from slot X to slot Y"
1809- operation. The legacy (web-player) bucket is the one exception — it is read-only and cannot be deleted from the plugin
1856+ Copying a single save from one slot into another is supported via the per-save ** "Copy to slot…"** action (see
1857+ [ Copy a save to another slot] ( #copy-a-save-to-another-slot ) ) — the target slot becomes the ROM's active slot and the
1858+ source save is preserved. What is _ not_ supported is a bulk ** move** of a whole slot's contents (delete-from-source
1859+ semantics): users copy individual saves, then delete the source slot from the server if they want it gone. The legacy
1860+ (web-player) bucket is a read-only copy _ source_ — it cannot be a copy target, and cannot be deleted from the plugin
18101861(#1478 ).
18111862
18121863### Cross-device save browsing limited
0 commit comments