Skip to content

fix(cloud-sync): remap web-player states into RetroArch's numbered load slots - #3933

Merged
gantoine merged 9 commits into
rommapp:feat/retroarch-cloud-syncfrom
fmustafayaman:fix/state-manifest-slot-filter
Jul 24, 2026
Merged

fix(cloud-sync): remap web-player states into RetroArch's numbered load slots#3933
gantoine merged 9 commits into
rommapp:feat/retroarch-cloud-syncfrom
fmustafayaman:fix/state-manifest-slot-filter

Conversation

@fmustafayaman

Copy link
Copy Markdown

Summary

Follow-up to #3931 (merged). Found while live-testing that PR's merged branch against a real RetroArch client: a state created through RomM's own web player (EmulatorJS) carries a display label + timestamp in its file name, not a RetroArch slot number. Since the State model has no slot column (unlike Save), such a state was surfaced in the cloud-sync manifest under its raw file name.

Reproduced live: RetroArch's Load State menu only ever offers numbered slots 0-999. A web-player state synced this way downloads successfully to disk but is never reachable from that menu -- it just silently sits in the sync folder.

Fix

Mirrors the equivalent (already-shipped, already-live) fix in the retroarch-webdav-romm shim project's manifest builder: states are grouped by (rom, emulator, slot suffix), where the slot suffix is derived from the file name's trailing .state / .state<N> / .state.auto pattern. The newest state in each bucket -- regardless of whether it came from RetroArch itself or the web player -- is advertised in the manifest under the canonical name RetroArch's own upload for that slot would carry (<rom>.state, <rom>.state3, <rom>.state.auto, ...).

GET/DELETE resolve that canonical name back to the real underlying row via a new resolve_state_by_slot, so serving and deleting always agree with what the manifest advertised, even when the underlying row's actual file_name differs from the canonical one.

Saves are untouched -- they already have a real slot column and are filtered correctly (slot is None excludes web-player saves from the manifest).

Test plan

  • 65/65 cloud-sync tests passing (new: TestCloudSyncStateSlotResolution, test_remaps_web_player_state_to_canonical_slot, test_newest_state_in_a_slot_wins_regardless_of_origin)
  • Full backend suite: 2385 passed, 2 skipped, 0 failed -- no regressions
  • ruff check clean
  • Verified live against a real RetroArch 1.22.2 client synced to a deployed test instance running this branch: reproduced the bug (web-player state downloaded but unreachable from Load State), confirmed the fix resolves it

…ad slots

States have no `slot` column (unlike saves), so a state created through
RomM's own web player -- named with a display label and timestamp, not a
RetroArch slot number -- was surfaced in the cloud-sync manifest under its
raw file name. Verified live: RetroArch's Load State menu only ever offers
numbered slots 0-999, so a synced file named otherwise downloads
successfully but is never reachable from that menu.

Mirrors the reasoning already used for the retroarch-webdav-romm shim
project's manifest builder: states are grouped by (rom, emulator, slot
suffix) via the file name's trailing .state/.state<N>/.state.auto pattern,
and the newest state in each bucket -- regardless of whether it came from
RetroArch itself or the web player -- is advertised under the canonical
name RetroArch's own upload for that slot would carry. GET/DELETE resolve
that canonical name back to the same bucket via resolve_state_by_slot, so
serving and deleting agree with what the manifest advertised even when the
underlying row's real file name differs.
RetroArch uploads a PNG screenshot alongside every state it syncs, named
`<state file name>.png` (e.g. `test_rom.state.png`). Verified live: this
file name doesn't match RetroArch's own `.state[N]`/`.state.auto` slot
pattern once naively split on the last dot ("test_rom.state" is not any
ROM's name), so `_resolve_rom` failed and every screenshot upload 409'd.

game_name_from_file_name now strips a trailing `.png` before applying the
existing state-suffix stripping, so it resolves the owning ROM the same way
it would for the state itself. Screenshots have no ROM-name-derived slot of
their own -- they ride along with whichever state `resolve_state_by_slot`
already picked for that (rom, emulator, slot), via the new
`resolve_state_screenshot_by_slot`, and are stored as a Screenshot (RomM's
own state-thumbnail model), not a State.

build_manifest now also advertises a `<canonical state name>.png` entry
for any state that has one attached, so RetroArch's own upload of it
succeeds and diffs correctly on subsequent syncs instead of failing (and
retrying) forever.
…g path

PUT wrote every state upload under the canonical slot name RetroArch
requested and only patched file_size_bytes on the matched existing row --
but that row's own file_name (e.g. a web-player upload's timestamped name)
was left untouched. The new bytes landed on disk under a different path
than the DB row pointed at, so the row's real content and what's actually
on disk silently diverged.

Verified live: this surfaced as a state that could never stop being a
'Conflicting change' on every subsequent sync, no matter how many times it
was re-uploaded -- each upload wrote to the canonical path again while the
tracked row (and its real save data) still pointed at the old, now-stale
file, so RomM's own view of "the current state" never actually caught up
to what RetroArch kept sending.

Now resolves the existing row first and writes to *its* real file name
when one exists, only falling back to the canonical name for a genuinely
new row. Same fix applied to the state-screenshot upload path, which had
the identical bug (screenshot identity resolved by exact canonical-name
match instead of the owning state's real name).
…h sync

PPSSPP doesn't save a single file per game like every other core -- it
mirrors a real PSP memory stick under saves/<core>/PSP/SAVEDATA/<slot>/
as several small files (PARAM.SFO, the actual save data, ICON0.PNG, ...)
that only make sense as a set, plus saves/<core>/PSP/SYSTEM/CACHE/ holds
pure engine caches with no save data at all. Neither fits the existing
one-WebDAV-path-per-asset model.

Ports the retroarch-webdav-romm shim's pspSave.ts approach: a save
folder's files are bundled into a single zip stored as one RomM Save
(slot=None), and unbundled again for GET/manifest purposes. The rom is
resolved from the folder's PARAM.SFO TITLE (normalized-matched against
roms on the psp platform) with PSP_SERIAL_MAP as an explicit override and
fallback for titles that don't normalize-match automatically. Files that
arrive before the folder resolves (RetroArch doesn't guarantee PARAM.SFO
lands first) are buffered on disk until it does, and merged in once
resolved.

Manifest lists each bundle member as its own {path, hash} entry, since
RetroArch diffs per-file, not per-bundle.

Also fixes a latent zipfile bug this surfaced: zipfile_inflate64 (pulled
in elsewhere for ROM archive reading) replaces zipfile._get_compressor
with a signature CPython 3.13's own ZipFile.writestr() can't call --
RomM already has a shim for this (utils/zip_cache.py's
_ensure_zipfile_writable), just needed calling here too.
@gantoine
gantoine self-requested a review July 24, 2026 14:59
@fmustafayaman

Copy link
Copy Markdown
Author

Verified the PSP bundling live against a real PPSSPP capture (Patapon (Europe), serial UCES00995) synced from an actual RetroArch 1.22.2 client to a deployed instance of this branch:

  • Both save slots (UCES00995_DATA00, UCES00995_DATA01) resolved to the correct rom automatically via PARAM.SFO title matching — no PSP_SERIAL_MAP override needed.
  • Reproduced the exact out-of-order-upload scenario the retroarch-webdav-romm shim's pspSave.ts already documented: DATA01/ICON0.PNG reached the server before DATA01/PARAM.SFO and got a 409 (folder not yet resolved), same as an unbuffered implementation would. Once PARAM.SFO arrived and resolved the rom, the buffered ICON0.PNG was correctly folded into the bundle — confirmed via the server manifest afterward, which lists all three members (ICON0.PNG, PARAM.SFO, SECURE.BIN) for both save slots with correct hashes.
  • A PPSSPP save-state and its screenshot (Patapon (Europe) (En,Fr,De,Es,It).state[.png]) synced correctly alongside the PSP saves.
  • Full sync: 9 files uploaded, 0 permanent failures (the one transient 409 above self-resolved within the same sync pass).

No code changes from this — just confirming the buffering/merge logic (previously only unit- and mock-tested) holds up against real out-of-order PPSSPP upload traffic, not just the synthetic ordering my tests assumed.

…brary

RetroArch's own Cloud Sync client never issues PROPFIND (verified against
its source, already noted in this router's docstring), so this isn't on
RetroArch's actual sync path -- it's for a real WebDAV client (iOS Files'
"Connect to Server", Cyberduck, ...) to mount the same /api/cloud-sync
URL and browse/download the library as plain files, same as the
retroarch-webdav-romm shim's romBrowser.ts + webdavXml.ts.

Adds PROPFIND for roms/<platform>/<file> (RomM's own library, read-only --
no PUT/DELETE) and saves/states/ (the current cloud-sync manifest; unlike
the shim, only current entries are browsable here, not full history --
RomM's own web UI covers that). LOCK/UNLOCK are a fake always-succeeds
handshake some WebDAV clients require before they'll mount a server at
all, matching the shim.

GET/HEAD for a rom file redirects (307) to RomM's existing
/api/roms/{id}/content/{file_name} endpoint rather than reimplementing
Range support, multi-file zip caching and (in production) nginx
X-Accel-Redirect -- Basic Auth carries over on the redirect since that
endpoint already accepts it alongside OAuth.

Found and fixed along the way: Rom.has_multiple_files / .files depend on
columns/relationships get_roms_scalar doesn't eager-load, so accessing
them outside the query's own session raised DetachedInstanceError --
re-fetch the visibility-filtered ids via get_roms_by_ids (which does
eager-load them) instead of using the raw scalar results directly.
…ilter

# Conflicts:
#	backend/endpoints/cloud_sync.py
…orever

Verified live with a real WebDAV client (Cyberduck): every PropfindEntry
href was relative to this router's own mount point (e.g. "roms/",
"saves/Snes9x/"), not absolute from the server root the way WebDAV clients
expect a <D:href> to be. The client couldn't match a "self" entry's href
back to the path it had just requested, so it rendered that entry as an
extra nested subfolder instead of recognizing it as the current directory
-- and since every subfolder has the same mismatch, browsing into it
produced another apparent copy of the whole tree, forever.

Prefixing every href with this router's actual mount path
(/api/cloud-sync) fixes the self-entry match and ends the loop.

Also fixed along the way: href path segments were never percent-encoded,
unlike the retroarch-webdav-romm shim's own hrefEscape (which used
encodeURIComponent) that this was supposed to mirror -- a save/state/rom
filename containing a space or other reserved character would have
produced invalid hrefs.
sanitize_filename treated "+" as an invalid character alongside the
actual Windows-reserved set (< > : " / \ | ? *), which broke cloud-sync's
filename-based ROM matching for any title containing one -- RomM itself
stores such ROMs (e.g. combo carts like "Super Mario All-Stars + Super
Mario World") on disk with the "+" intact, so stripping it before
resolving the ROM caused every save/state upload for that game to 409.

Confirmed live against production (romm.vmyaman.com) via a real
RetroArch client.
Keeping the test-file footprint down; the production fix (dropping "+"
from sanitize_filename's invalid-char set) stands on its own and is
covered live.
@gantoine
gantoine merged commit 505f457 into rommapp:feat/retroarch-cloud-sync Jul 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants