Skip to content

Add device-based save synchronization#2917

Merged
gantoine merged 16 commits intorommapp:masterfrom
tmgast:feature/device-registration-save-sync
Feb 4, 2026
Merged

Add device-based save synchronization#2917
gantoine merged 16 commits intorommapp:masterfrom
tmgast:feature/device-registration-save-sync

Conversation

@tmgast
Copy link
Member

@tmgast tmgast commented Jan 18, 2026

Description

Adds device-based save synchronization to enable multi-device save management. Devices (handhelds, PCs, etc.) can register with the server and track which saves they've synced, enabling conflict detection when a device tries to upload stale data.

Features

  • Device Registration: Devices register once and receive a persistent UUID
  • Sync Tracking: Each device tracks when it last synced each save file
  • Conflict Detection: Uploading stale data returns 409 with details; bypass with overwrite=true
  • Track/Untrack: Devices can opt out of syncing specific saves
  • Slot-Based Saves: Optional slot parameter for categorizing saves into named slots
  • Datetime Tagging: Filenames auto-tagged with [YYYY-MM-DD_HH-MM-SS] when slot provided
  • Autocleanup: Optional autocleanup + autocleanup_limit to prune old slot saves
  • Save Summary: Aggregated slot information via /saves/summary

Note

This is a foundational PR to set up an initial structure and API implementation for devices and save syncing in preparation for client application usage and eventual alternative sync modes and use-cases.

Known Gaps

  • no registered device recovery on duplicate/repeat registration
  • sync_mode defaults to API for all devices
  • no batch update mechanic
  • file-transfer and push-pull modes not handled (out of scope)
  • features not exposed to the front-end

New Models

Device

Field Type Description
id str (PK) UUID assigned on registration
user_id int (FK) Owner of the device
name str? User-friendly name (e.g., "Odin3", "Pocket Mini v2")
platform str? OS/platform (e.g., "android", "linux")
client str? Client app name (e.g., "argosy", "grout")
client_version str? Client version
ip_address str? Last known IP
mac_address str? MAC address
hostname str? Device hostname
sync_mode SyncMode Enum: api, file_transfer, push_pull
sync_enabled bool Whether sync is enabled (default: true)
last_seen datetime? Last activity timestamp

DeviceSaveSync

Field Type Description
device_id str (PK, FK) References devices.id
save_id int (PK, FK) References saves.id
last_synced_at datetime When device last synced this save
is_untracked bool Device opted out of syncing this save
is_current bool Computed: device has the current save version

DeviceSchema Sample

{
  "id": "abc-123-uuid",
  "user_id": 1,
  "name": "Steam Deck",
  "platform": "linux",
  "client": "RetroArch",
  "client_version": "1.17.0",
  "ip_address": "192.168.1.100",
  "mac_address": "AA:BB:CC:DD:EE:FF",
  "hostname": "steamdeck",
  "sync_mode": "api",
  "sync_enabled": true,
  "last_seen": "2025-01-18T14:30:00Z",
  "created_at": "2025-01-15T10:00:00Z",
  "updated_at": "2025-01-18T14:30:00Z"
}

SaveSchema Sample

{
  "id": 42,
  "rom_id": 100,
  "user_id": 1,
  "file_name": "pokemon_emerald [2025-01-18_14-00-00].sav",
  "file_name_no_tags": "pokemon_emerald",
  "file_name_no_ext": "pokemon_emerald [2025-01-18_14-00-00]",
  "file_extension": "sav",
  "file_path": "/saves/gba/100",
  "file_size_bytes": 131072,
  "full_path": "/saves/gba/100/pokemon_emerald [2025-01-18_14-00-00].sav",
  "download_path": "/api/saves/42/content",
  "missing_from_fs": false,
  "created_at": "2025-01-10T08:00:00Z",
  "updated_at": "2025-01-18T14:00:00Z",
  "emulator": "mgba",
  "slot": "Main Playthrough",
  "screenshot": null,
  "device_syncs": [
    {
      "device_id": "abc-123-uuid",
      "device_name": "Steam Deck",
      "last_synced_at": "2025-01-17T10:00:00Z",
      "is_untracked": false,
      "is_current": false
    }
  ]
}

SaveSummarySchema Sample

{
  "total_count": 6,
  "slots": [
    {
      "slot": null,
      "count": 3,
      "latest": { "...SaveSchema..." }
    },
    {
      "slot": "Main Playthrough",
      "count": 2,
      "latest": { "...SaveSchema..." }
    },
    {
      "slot": "Nuzlocke Run",
      "count": 1,
      "latest": { "...SaveSchema..." }
    }
  ]
}

New API Endpoints

Devices

POST /api/devices

  • Register a new device
REQUEST
{
  "name": "Steam Deck",
  "platform": "linux",
  "client": "RetroArch",
  "client_version": "1.17.0",
  "ip_address": "192.168.1.100",
  "mac_address": "AA:BB:CC:DD:EE:FF",
  "hostname": "steamdeck"
}

RESPONSE
{
  "device_id": "abc-123-uuid",
  "name": "Steam Deck",
  "created_at": "2025-01-18T12:00:00Z"
}

GET /api/devices

  • List all devices for the current user
RESPONSE
[DeviceSchema, ...]

GET /api/devices/{device_id}

  • Get device details
RESPONSE
DeviceSchema

PUT /api/devices/{device_id}

  • Update device properties
REQUEST
{
  "name": "Steam Deck (Docked)",
  "sync_enabled": true
}

RESPONSE
DeviceSchema

DELETE /api/devices/{device_id}

  • Delete a device and its sync records
RESPONSE: 204 No Content

Saves

GET /api/saves/summary?rom_id={rom_id}

  • Get aggregated slot information for a ROM
  • Returns total count, list of slots with counts, and latest save per slot
RESPONSE
{
  "total_count": 6,
  "slots": [
    {
      "slot": null,
      "count": 3,
      "latest": SaveSchema
    },
    {
      "slot": "Main Playthrough",
      "count": 2,
      "latest": SaveSchema
    }
  ]
}

Save Sync Operations

POST /api/saves/{id}/track

  • Re-enable sync tracking for a save on this device
REQUEST
{
  "device_id": "abc-123-uuid"
}

RESPONSE
SaveSchema

POST /api/saves/{id}/untrack

  • Opt out of syncing this save on this device
REQUEST
{
  "device_id": "abc-123-uuid"
}

RESPONSE
SaveSchema

POST /api/saves/{id}/downloaded

  • Confirm download completed (for non-optimistic sync)
REQUEST
{
  "device_id": "abc-123-uuid"
}

RESPONSE
SaveSchema

Updated API Endpoints

POST /api/saves

  • Added device_id query param to activate sync features
  • Added slot query param for slot categorization (optional)
  • Added autocleanup query param (default: false)
  • Added autocleanup_limit query param (default: 10)
  • When slot provided, filename auto-tagged with [YYYY-MM-DD_HH-MM-SS]
  • Existing datetime tags in filename are replaced, not appended
  • Returns 409 on conflict when device has stale sync
409 RESPONSE (without slot - destructive conflict)
{
  "detail": {
    "error": "conflict",
    "message": "Save has been updated since last sync and will be overwritten",
    "save_id": 42,
    "current_save_time": "2025-01-18T14:00:00Z",
    "device_sync_time": "2025-01-17T10:00:00Z"
  }
}

409 RESPONSE (with slot - out of sync deviation)
{
  "detail": {
    "error": "out_of_sync",
    "message": "Save has been updated since last sync",
    "save_id": 42,
    "current_save_time": "2025-01-18T14:00:00Z",
    "device_sync_time": "2025-01-17T10:00:00Z"
  }
}

GET /api/saves

  • Added device_id query param
  • Added slot query param to filter by slot
  • Returns device_syncs[] with sync status when device_id provided

GET /api/saves/{id}

  • Added device_id query param
  • Returns device_syncs[] with sync status when device_id provided

GET /api/saves/{id}/content

  • Added device_id query param
  • Added optimistic query param (default: true)
  • When optimistic=true, updates sync record on download

New Scopes

  • devices.read: Read device information
  • devices.write: Register/modify/delete devices

Database Changes

  • Migration: 0068_save_sync.py

Recommended Usage

Device Registration (First Launch)

User login -> register new device -> store device ID for save sync API calls

Discover Save Slots (Game Selected)

Fetch save summary to populate slot picker UI

GET /api/saves/summary?rom_id={rom_id}

  • Returns all slots with counts and latest save per slot
  • Use to build slot selection UI:
    • "Main Playthrough" (3 saves, last: Jan 30)
    • "Nuzlocke Run" (1 save, last: Jan 15)
    • No slot (5 saves, last: Jan 28)

Start Game Flow

If loading specific slot:

GET /api/saves?rom_id={rom_id}&slot={slot_name}&device_id={device_id}

  • Get saves for selected slot only
  • Check device_syncs[0].is_current on latest
    • true -> local save is current, safe to play
    • false -> download latest before playing

GET /api/saves/{id}/content?device_id={device_id}

  • Downloads save file
  • With optimistic=true (default), sync record updates automatically

If loading latest across all slots:

Use summary's latest from each slot to find most recent globally

End Game Flow

Check sync status -> upload new save file

GET /api/saves/{id}?device_id={device_id}

  • Check device_syncs[0].is_current before uploading
    • true -> safe to upload
    • false -> warn user: server has newer save

POST /api/saves?rom_id={rom_id}&device_id={device_id}&slot={slot_name}

  • Upload save to specific slot
  • Filename auto-tagged with datetime (e.g., save [2025-01-18_14-30-00].sav)
  • Each upload creates new timestamped save in slot

With autocleanup (recommended for autosave slots):

POST /api/saves?rom_id={rom_id}&device_id={device_id}&slot=autosave&autocleanup=true&autocleanup_limit=10

  • Keeps only 10 most recent saves in "autosave" slot
  • Older saves automatically pruned

Note

To force overwrite on conflict:
POST /api/saves?rom_id={rom_id}&device_id={device_id}&overwrite=true


Slot Feature Details

Datetime Tagging

When uploading with a slot parameter:

  • Filename is automatically tagged: filename [YYYY-MM-DD_HH-MM-SS].ext
  • If filename already has a datetime tag, it is replaced (not double-appended)
  • Saves without slot parameter are not tagged (backward compatible)

Autocleanup

When uploading with autocleanup=true:

  • Applies only when slot is also provided
  • After upload, counts saves in that slot
  • Deletes oldest saves beyond autocleanup_limit (default: 10)
  • Useful for autosave slots to prevent unbounded growth

Summary Endpoint

GET /api/saves/summary?rom_id={rom_id} returns:

  • total_count: Total saves for this ROM
  • slots[]: Array of slot summaries, each with:
    • slot: Slot name (or null for unslotted saves)
    • count: Number of saves in this slot
    • latest: Full SaveSchema of most recent save in slot

AI Disclosure
Planning and review assisted by Claude Code

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Implement device registration and save sync tracking to enable
multi-device save management with conflict detection.

- Device CRUD endpoints (POST/GET/PUT/DELETE /api/devices)
- Save sync state tracking per device
- Conflict detection on upload (409 when device has stale sync)
- Download sync tracking (optimistic and confirmed modes)
- Track/untrack saves per device
- DEVICES_READ/WRITE scopes for authorization
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @tmgast, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request lays the groundwork for robust multi-device save management. It introduces new data models and API endpoints to enable devices to register, track save synchronization status, and handle potential conflicts when uploading game saves. The changes are foundational, providing the necessary backend infrastructure for future client-side integration and advanced sync modes, ultimately enhancing the user experience for players across various platforms.

Highlights

  • Multi-Device Save Synchronization: Introduced a comprehensive system for device-based save synchronization, allowing users to manage game saves across multiple devices (e.g., handhelds, PCs).
  • New Device Management API: Added a full suite of API endpoints for devices, including registration, listing, retrieving details, updating properties, and deletion. Devices are assigned persistent UUIDs upon registration.
  • Save Sync Tracking and Conflict Detection: Implemented tracking for when each device last synced a save file. The system now detects conflicts when a device attempts to upload stale data, returning a 409 Conflict status with details. Users can bypass this with an overwrite=true flag.
  • Save Tracking Controls: Devices can now explicitly track or untrack specific save files, opting in or out of synchronization for individual saves.
  • Database Schema and OAuth Scopes: New devices and device_save_sync tables have been added to the database, along with corresponding devices.read and devices.write OAuth scopes to control access to device management features.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a comprehensive pull request that introduces a foundational device-based save synchronization feature. The changes are well-structured, including new models, API endpoints, and an impressive suite of tests covering various scenarios like conflict detection and user isolation. My review focuses on a few key areas to enhance the robustness and security of the implementation. I've identified a critical issue in the database migration concerning an enum mismatch that could prevent the feature from working correctly. Additionally, there are some high-severity security concerns with incorrect scope definitions on new endpoints and a potential for subtle bugs in datetime handling. I've also included some medium-severity suggestions to improve code quality and maintainability. Overall, this is a strong contribution, and addressing these points will make it even better.

@BrandonKowalski
Copy link
Member

I have been tinkering with a naive save sync in Grout for about a month now and look forward to implementing what you've shared here. Right now I just use timestamps but this scheme will offer much more control.

I think this would also be the right time to take a look at adding the concept of "playthroughs" (name WIP). Right now, saves are essentially just a big bucket tied to game/user. A little extra organization will go a long way and I think will help make this syncing system even more robust.

I just saw another user mentioned it a few months ago on the Sync RFC: #2199 (comment)

Instead of registering saves we'd register playthroughs. Playthroughs could store a rolling list of saves (configurable maybe with a default of 5 or so) and will always return the metadata for the most recent save file associated with it. This would allow for the ability to rollback to be added in the future. I realize while this is technically achievable already with the single bucket of saves but I think being more explicit will prevent trouble down the road.

Also I think there needs to be a sync audit log from the start (even if it isn't exposed anywhere user facing to begin).

Just my two cents and happy to help where needed!

@tmgast
Copy link
Member Author

tmgast commented Jan 18, 2026

It's already loosely possible to create playthrough with this, but I think it could be solidified in this spec as well. Including the history in RomM would take some of the burden off of the client for storage (though I still think it's a good idea to have a local mirror of the history for offline rollback). I also like the idea of auditing changes with a log that could double as the rollback state selection list. The hard part is updating the UI so the historic saves aren't all dumped into the UI. Seems like I'll probably have to address that to move forward with the improvements.

@gantoine
Copy link
Member

would take some of the burden off of the client for storage

No directly related to that conversion, but given how limited some of the targeted devices can be I think it's useful to shift the burden onto the server as much as possible. We can build modules in isolation for sync without affecting the wider system.

@tmgast
Copy link
Member Author

tmgast commented Jan 18, 2026

Would we be better off overhauling current saves or, my preference, break synced playthroughs off to their own thing so we aren't muddying up the existing UI and model structures for save files?

@gantoine
Copy link
Member

Would we be better off overhauling current saves or, my preference, break synced playthroughs off to their own thing so we aren't muddying up the existing UI and model structures for save files?

I think that's a conversation we need to include @zurdi15 in. IMO the current save system isn't very practical, since it requires manually uploading or interfacing with the API, and the proposal would handle things like dropping/syncing folders and ingesting those saves. All that to say, a single system for handling saves would be ideal from a user POV and simpler in the code base. I haven't looked as the PR closely yet so I'll comment further when that's done.

@tmgast
Copy link
Member Author

tmgast commented Jan 18, 2026

Appreciate the input. I'd definitely like to get his opinion as well. I'm happy to put in the effort to make it happen. I'm going to work on a spec built on top of this branch over the next few days to see what would work best. I'll keep lower spec devices in mind too...

@zurdi15
Copy link
Member

zurdi15 commented Jan 19, 2026

I'm aligned with what Arcane is exposing here. A single saves/states system it's the ideal way of feeling it really as a central point where all your saves and satetes are not only managed but also that you can use any of them, anywhere.

The UI side should remain the same in terms of merging the new sync work (or just rework what we already have and add the sync part). The user should feel everything is the same and not to have two different systems for different purposes. I also agree that right now using the API to upload/download saves is not too practical, but the details about that are way too long to be discussing it here now in a comment, we should use the RFC for that

@gantoine gantoine assigned gantoine and unassigned gantoine Jan 29, 2026
@gantoine
Copy link
Member

@tmgast i've got time this weekend to review this PR, anything you want to change before i dive in?

@tmgast
Copy link
Member Author

tmgast commented Jan 31, 2026

I wanna add save revisions/history, but I have been too busy with the app to revisit this.

@gantoine
Copy link
Member

gantoine commented Jan 31, 2026

Maybe that happens in another PR and we limit this one to what you've built so far? Would make it easier to review on our end.

@tmgast
Copy link
Member Author

tmgast commented Jan 31, 2026

I'm good with that... though I'm debating my current implementation of naming here intended for save slots. Let me take another look over it today and make a few adjustments.

@tmgast
Copy link
Member Author

tmgast commented Jan 31, 2026

I'm wrapping up some hashing stuff now, since it's basically just moving over the process from Argosy to be handled by the server. I think this will be more reliable than having the clients deal with it and ensures the strategy is done correctly and consistently.

The main concern is with hashing compressed content since just hashing the container would always be different, even when compressing the same contents. This solution hashes the inner content and combines that into a single MD5 for comparison and storage.

I'll push the commit for review after a run some test files through it.

- Add device registration and save synchronization
- Implement slot-based save organization with datetime tagging
- Add conflict detection for multi-device sync scenarios
- Add content hash computation for save deduplication
- Support ZIP inner-file hashing for consistent deduplication
- Add confirm_download endpoint for sync state management
- Add overwrite parameter to bypass conflict checks
@tmgast tmgast force-pushed the feature/device-registration-save-sync branch from b06e897 to a236123 Compare January 31, 2026 13:03
Copy link
Member

@gantoine gantoine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

damn this is noice *chef kiss*

Add fingerprint-based detection for duplicate device registration with
configurable behavior via new body params:
- allow_existing: return existing device if fingerprint matches
- allow_duplicate: skip fingerprint check, always create new device
- reset_syncs: clear tracked saves when reclaiming existing device

Fingerprint matching uses mac_address (primary) or hostname+platform
(fallback). Returns 409 Conflict with device_id when duplicate detected
without flags, 200 OK for existing device, 201 Created for new.
Replace individual Body() parameters with DeviceCreatePayload and
DeviceUpdatePayload Pydantic models. This simplifies the function
signatures and leverages model_dump(exclude_unset=True) for cleaner
update handling.
Add order_by and order_dir parameters to get_saves() for flexible
sorting. Supports "updated_at" and "created_at" fields with "asc" or
"desc" direction (default: desc). Enables ascending order for pruning
scenarios.
Move compute_file_hash, compute_zip_hash, and compute_content_hash from
scan_handler.py to filesystem/assets_handler.py as standalone module-level
functions. This follows the existing pattern for utility functions in
filesystem handlers.
Move UTC datetime normalization to a dedicated utils module for
reusability across the codebase.
Add tests for duplicate device registration scenarios (409 conflict,
allow_existing, allow_duplicate, reset_syncs). Fix compute_file_hash
function references after relocation to assets_handler.
Add model_validator to SaveSchema that safely handles SQLAlchemy lazy
relationships by checking inspect(obj).unloaded before attribute access.
This allows direct use of model_validate(orm_obj) instead of manually
building a dict and excluding device_syncs.
…stration

Change allow_existing default to True so duplicate fingerprint matches
return the existing device (200) instead of 409 Conflict. Add model
validator to force allow_existing=False when allow_duplicate is set.
@gantoine gantoine merged commit 14c0676 into rommapp:master Feb 4, 2026
5 of 6 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.

4 participants