Skip to content

Commit 8ea893e

Browse files
authored
feat(saves): copy a save into another slot (#1556)
Adds a per-save "Copy to slot…" action on the SAVES tab: pick a specific save — from any slot, including the read-only no-slot bucket — and copy it into another slot, an existing named one or a new one. The source save is left untouched (it's a copy, never a delete); the target slot becomes the ROM's active, tracked slot with the copied save as its current save. This covers promoting a specific no-slot save into a named slot, bringing a save from the old "default" slot onto "autosave", and copying a save from an inactive slot into the active one. The action is online-only and refuses an unconfigured ROM (set save sync up through the wizard first). Copying into a slot another device changed since this device's last sync surfaces a "resolve conflicts first, then copy again" prompt rather than overwriting. Closes #1523
1 parent e3ecbca commit 8ea893e

25 files changed

Lines changed: 1828 additions & 59 deletions

.size-limit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
"name": "dist/index.js (raw)",
44
"path": "dist/index.js",
55
"brotli": false,
6-
"limit": "800 KB"
6+
"limit": "820 KB"
77
}
88
]

docs/architecture/save-file-sync-architecture.md

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

12381287
The 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,
18031852
emulator-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

docs/user-guide/save-sync.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,26 @@ The modal blocks the Play action until you choose. If a post-exit sync detects a
115115
opens the next time you tap Play, where it blocks launch until resolved. There is no longer a separate "pending
116116
conflicts" list on the settings page.
117117

118+
## Copying a Save to Another Slot
119+
120+
On the SAVES tab, most save rows carry a **Copy to slot…** button — the active slot's current save, its Previous
121+
Versions, and the saves listed under any other slot (including the read-only legacy web-player bucket). Tap it to copy
122+
that save into another slot:
123+
124+
- Pick an existing slot, or type a name to create a new one.
125+
- The **copy is not a move** — the original save stays where it is. Delete it from the RomM web app later if you want it
126+
gone.
127+
- The slot you copy into **becomes the game's active slot**, with the copied save as its current save.
128+
- If that save's content is **already in the destination slot**, nothing is copied — you're told it's already there
129+
(shown as `#<id>`).
130+
131+
Common uses: promote an old web-player (legacy) save into a proper named slot, or bring a save from one slot onto
132+
another so you can continue it there.
133+
134+
The button is unavailable while RomM is offline. If the game's current slot has an unresolved conflict, resolve it first
135+
(you'll be prompted). If the destination slot has newer changes from another device, sync that slot first, then copy
136+
again.
137+
118138
## Core Switch Warning
119139

120140
When you switch the emulator core for a game (e.g., from mGBA to gpSP for GBA), the plugin detects the change and shows

main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,10 @@ async def saves_list_file_versions(self, rom_id, slot, filename):
566566
async def saves_rollback_to_version(self, rom_id, slot, save_id):
567567
return await self._save_sync_service.rollback_to_version(rom_id, slot, save_id)
568568

569+
@migration_blocked
570+
async def copy_save_to_slot(self, rom_id, save_id, target_slot):
571+
return await self._save_sync_service.copy_save_to_slot(rom_id, save_id, target_slot)
572+
569573
async def record_session_start(self, rom_id):
570574
result = self._playtime_service.record_session_start(rom_id)
571575
# Fire-and-forget: drain any offline play-session backlog into RomM's

0 commit comments

Comments
 (0)