Skip to content

feat(cloud save config): mirror game lib structure - #434

Open
Shellywell123 wants to merge 3 commits into
JMBeresford:mainfrom
Shellywell123:feat/server-config-cloud-save-method-mirror-gamelibrary-structure
Open

feat(cloud save config): mirror game lib structure#434
Shellywell123 wants to merge 3 commits into
JMBeresford:mainfrom
Shellywell123:feat/server-config-cloud-save-method-mirror-gamelibrary-structure

Conversation

@Shellywell123

@Shellywell123 Shellywell123 commented Nov 9, 2025

Copy link
Copy Markdown

A new cloud save method that will mirror the structure of the users game library.

The new Mirror Game Library Structure option under cloud save configurations, will mimic the structure of the users game library. This is an attempt to improve the current Emulator/Game ID based system, where the IDs are dependant on the order you setup emulators and import games. The advantage of this new method is that the mapping of saves to games persists even if the database is destroyed.

Web Frontend

New drop down allowing the user to specify the file save path structure.
Screenshot from 2025-11-07 20-02-30

Server File structure

example game lib

> tree app/example-library/
app/example-library/
└── gameboy
    ├── pokemon
    │   └── pokemon.gb
    ├── tetris
    │   └── Tetris.gb
    └── zelda
        └── zelda.gb

5 directories, 3 files

default Emulator/Game ID basedmethod

> tree ~/.local/share/server/saves/
/home/shellywell123/.local/share/server/saves/
└── 1
    ├── 2
    │   └── data
    │       └── saves
    │           └── mGBA
    │               └── 3.srm
    └── 6
        └── data
            └── saves
                └── mGBA
                    └── 5.srm

10 directories, 2 files

new Mirror Game Library Structure method

> tree ~/.local/share/server/saves/
/home/shellywell123/.local/share/server/saves/
└── gameboy
    ├── pokemon
    │   └── data
    │       └── saves
    │           └── mGBA
    │               └── 2.srm
    └── zelda
        └── data
            └── saves
                └── mGBA
                    └── 1.srm

10 directories, 2 files

Disclaimer: This was built heavily with AI using sonnet 4.5 and copilot

@Shellywell123 Shellywell123 changed the title A new cloud save method that will mirror the structure of the users g… feat(cloud save config): mirror game lib structure Nov 9, 2025
@JMBeresford
JMBeresford requested a review from Copilot November 9, 2025 19:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull Request Overview

This pull request adds a configurable save directory structure feature, allowing users to choose between emulator/game ID-based paths or a structure that mirrors the game library organization. The implementation spans protobuf schema changes, backend Rust services, and frontend TypeScript UI components.

Key changes:

  • Introduced a new SaveDirStructure enum with two options: EMULATOR_GAME (default) and MIRROR_LIBRARY
  • Modified save file and save state managers to support dynamic path resolution based on configuration
  • Added UI controls for users to select their preferred directory structure

Reviewed Changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
packages/codegen/protos/retrom/server/config.proto Added SaveDirStructure enum and save_dir_structure field to SavesConfig message
packages/codegen/protos/retrom/services/server-config.proto Added deprecation notice that file was moved
packages/service-common/src/config/mod.rs Set default save_dir_structure configuration to EmulatorGame
packages/grpc-service/src/saves/save_file_manager.rs Implemented mirror library path structure logic for save files with async config access
packages/grpc-service/src/saves/save_state_manager.rs Implemented mirror library path structure logic for save states with async config access
packages/grpc-service/src/saves/mod.rs Added .await calls for async directory path resolution methods
packages/client-web/src/components/modals/config/server/saves-config.tsx Added UI dropdown for selecting save directory structure preference
packages/client-web/src/queries/saveFiles.tsx Added optional enabled parameter to useGetSaveFiles query hook
packages/client-web/src/providers/emulator-js/ejs-session.tsx Added fallback for undefined game.id when constructing save file query
packages/client-web/vite.config.ts Added error handler to silently ignore telemetry connection refused errors
packages/client/gen/schemas/linux-schema.json Auto-generated schema updates for Tauri permissions and capabilities

Comment on lines +290 to +294
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
tracing::info!("Final save path: {:?}", save_path);
return Ok(saves_dir.join(save_path));

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = saves_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&saves_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +293
tracing::info!(
"get_saves_dir - Game ID: {}, Save dir structure config: {:?}, MirrorLibrary enum value: {}, comparison: {}",
self.game.id,
save_dir_structure,
mirror_library_value,
save_dir_structure.map(|s| s == mirror_library_value).unwrap_or(false)
);

if save_dir_structure.map(|s| s == mirror_library_value).unwrap_or(false) {
let game_path = PathBuf::from(&self.game.path);
tracing::info!("Mirror library mode - Game path: {:?}", game_path);

// Find the content directory that contains this game
let content_dirs = &config.content_directories;
tracing::info!("Content directories: {:?}", content_dirs);
for content_dir in content_dirs {
let content_path = PathBuf::from(&content_dir.path);
tracing::info!("Checking content path: {:?}", content_path);

// Try to get the relative path from the content directory to the game
if let Ok(relative_game_path) = game_path.strip_prefix(&content_path) {
tracing::info!("Found matching content dir! Relative game path: {:?}", relative_game_path);

// Extract platform and game name from the relative path
let path_components: Vec<&str> = relative_game_path.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();

tracing::info!("Path components: {:?}", path_components);

// For both single-file and multi-file games, we want platform/game_name structure
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];

tracing::info!("Using platform: {:?}, game_name: {:?}", platform, game_name);

// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
tracing::info!("Final save path: {:?}", save_path);

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

Excessive debug logging should be removed before merging to production. Lines 246-293 contain numerous tracing::info! calls that log detailed internal state including path components, configuration values, and directory structures. This adds noise to logs and could expose sensitive information about the file system structure.

Consider either:

  • Removing these debug logs entirely, or
  • Changing them to tracing::debug! level so they're only visible when explicitly enabled for debugging

Copilot uses AI. Check for mistakes.
Comment on lines +356 to +359
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(backup_dir.join(save_path));

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = backup_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&backup_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
.join(emulator_id.to_string())
.join(self.game.id.to_string()))
// Check if we should use the game library path structure
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The complex conditional logic on line 251 is difficult to read and understand. The nested as_ref(), and_then(), map(), and unwrap_or() chain with an embedded enum comparison makes it hard to follow the logic flow.

Consider extracting this to a helper method or variable:

let should_use_mirror_library = config.saves
    .as_ref()
    .and_then(|s| s.save_dir_structure)
    .map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
    .unwrap_or(false);

if should_use_mirror_library {
    // ...
}

This improves readability and makes the condition's purpose clearer.

Suggested change
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
let should_use_mirror_library = config.saves
.as_ref()
.and_then(|s| s.save_dir_structure)
.map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
.unwrap_or(false);
if should_use_mirror_library {

Copilot uses AI. Check for mistakes.
.join(emulator_id.to_string())
.join(self.game.id.to_string()))
// Check if we should use the game library path structure
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The complex conditional logic on line 317 is difficult to read and understand. The nested as_ref(), and_then(), map(), and unwrap_or() chain with an embedded enum comparison makes it hard to follow the logic flow.

Consider extracting this to a helper method or variable:

let should_use_mirror_library = config.saves
    .as_ref()
    .and_then(|s| s.save_dir_structure)
    .map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
    .unwrap_or(false);

if should_use_mirror_library {
    // ...
}

This improves readability and makes the condition's purpose clearer.

Suggested change
if config.saves.as_ref().and_then(|s| s.save_dir_structure).map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32).unwrap_or(false) {
let should_use_mirror_library = config.saves
.as_ref()
.and_then(|s| s.save_dir_structure)
.map(|s| s == retrom_codegen::retrom::SaveDirStructure::MirrorLibrary as i32)
.unwrap_or(false);
if should_use_mirror_library {

Copilot uses AI. Check for mistakes.
Comment on lines +279 to +282
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(backup_dir.join(save_path));

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = backup_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&backup_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.
Comment thread packages/grpc-service/src/saves/save_file_manager.rs Outdated
Comment on lines +328 to +349
if let Some(game_dir) = relative_game_path.parent() {
let path_components: Vec<&str> = game_dir.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();

// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];

// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));
}

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

Inconsistent path extraction logic between save file and save state managers. In save_file_manager.rs, the path components are extracted directly from relative_game_path (line 270), while in save_state_manager.rs, they're extracted from relative_game_path.parent().

This means:

  • Save files: Uses path structure like platform/game_name from the full game file path
  • Save states: Uses path structure like platform/game_name from the parent directory of the game file path

This could lead to different directory structures for save files vs save states for the same game, which is likely unintended. Consider standardizing the path extraction logic across both managers to ensure consistency.

Suggested change
if let Some(game_dir) = relative_game_path.parent() {
let path_components: Vec<&str> = game_dir.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();
// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];
// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));
}
// Extract path components directly from relative_game_path (not its parent)
let path_components: Vec<&str> = relative_game_path.components()
.filter_map(|comp| {
if let std::path::Component::Normal(os_str) = comp {
os_str.to_str()
} else {
None
}
})
.collect();
// We want platform/game_name structure (first two components)
if path_components.len() >= 2 {
let platform = path_components[0];
let game_name = path_components[1];
// Sanitize each component
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 53
const saveFilesQuery = useGetSaveFiles({
saveFilesSelectors: [{ gameId: game.id, emulatorId: emulator?.id }],
saveFilesSelectors: [
{
gameId: game?.id ?? 0,
emulatorId: emulator?.id,
},
],
});

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

Potential issue with the query being enabled even when game ID is 0. The fallback game?.id ?? 0 on line 49 means the query will execute with gameId: 0 when game is undefined. This could result in unnecessary API calls with invalid data.

Consider either:

  1. Disabling the query when game is not available using the new enabled option:
const saveFilesQuery = useGetSaveFiles(
  {
    saveFilesSelectors: [{
      gameId: game?.id ?? 0,
      emulatorId: emulator?.id,
    }],
  },
  { enabled: !!game?.id }
);
  1. Or conditionally call the hook only when game is available.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +348
if !platform.contains("..") && !game_name.contains("..")
&& !platform.is_empty() && !game_name.is_empty() {
let save_path = format!("{}/{}", platform, game_name);
return Ok(states_dir.join(save_path));

Copilot AI Nov 9, 2025

Copy link

Choose a reason for hiding this comment

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

The path sanitization logic is insufficient to prevent path traversal attacks. The check !platform.contains("..") and !game_name.contains("..") only prevents literal ".." sequences, but doesn't catch other dangerous patterns like absolute paths, symlinks, or encoded path traversal sequences.

Consider using a more robust path validation approach:

  • Use .canonicalize() to resolve symbolic links and normalize paths
  • Verify the final path still starts with the expected base directory
  • Check for null bytes or other special characters

Example:

let final_path = states_dir.join(&save_path).canonicalize()?;
if !final_path.starts_with(&states_dir.canonicalize()?) {
    return Err(anyhow!("Invalid path traversal detected"));
}

Copilot uses AI. Check for mistakes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@JMBeresford

Copy link
Copy Markdown
Owner

@Shellywell123 Ignore the copilot review for now -- I'll go over its comments and let you know if any are relevant once I have time to review everything myself. Thanks! 🙂

Shellywell123 and others added 2 commits November 11, 2025 16:59
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Shellywell123 <44142139+Shellywell123@users.noreply.github.com>
@JMBeresford

Copy link
Copy Markdown
Owner

@Shellywell123 There are a couple concerns I have with this approach:

  1. Not all emulators for a given system will handle save files in the same way -- this is why saves are currently stored in the way they are. Opting out of an emulator identifier in favor of just the system it emulates means this structure will not be portable across all use cases. In other words, this will break for some sub-set of users in the future. Ultimately, saves must be managed on a per-emulator basis.
  2. Moving away from numerical IDs to name-based folders for this structure leads to quite a bit of fragility as a consequence. When a user renames a library entry, for example, these saves would now also need to be carefully considered and renamed. This would need to all be done atomically, so that an error in renaming one thing does not leave orphaned saves.

Is the goal of this change solely to guard against a loss or failure of the Retrom DB? If so, I would highly prefer to dedicate time and effort into a proper backup+restore solution with the DB in mind.

What are your thoughts?

@Shellywell123

Shellywell123 commented Nov 24, 2025

Copy link
Copy Markdown
Author

@JMBeresford To address your concerns + ask some clarifying questions back. :)

  1. "Not all emulators for a given system will handle save files in the same way"
    I totally agree, and as a User I also want my saves to be on a "per emulator basis" and believe my solution does also solve for this. The new file paths of the saves would still include the emulator name.
    As stated in the description the new file path would become:
    /home/shellywell123/.local/share/server/saves/gameboy/pokemon/data/saves/mGBA/2.srm
    where mGBA is the emulator.
    Maybe I am missing some context here, but why do we need an emulator id in the path at all? Is just having the emulator name not enough?

  2. "numerical IDs to name-based folders for this structure leads to quite a bit of fragility"
    I once again agree. That a messy/modified library will lead to issues.
    What are your thoughts on including it with a warning or alternatively exploring the use of IGDB metadata, to keep things uniform?

@JMBeresford
JMBeresford force-pushed the main branch 8 times, most recently from b46014c to ee77021 Compare January 27, 2026 02:38
@JMBeresford
JMBeresford force-pushed the main branch 12 times, most recently from 1df97b4 to 6214270 Compare February 3, 2026 05:14
@JMBeresford
JMBeresford force-pushed the main branch 4 times, most recently from eb02a1d to e738973 Compare February 5, 2026 01:16
@JMBeresford

JMBeresford commented Feb 5, 2026

Copy link
Copy Markdown
Owner

@Shellywell123

Maybe I am missing some context here, but why do we need an emulator id in the path at all? Is just having the emulator name not enough?

The data/saves/mGBA... suffix of that path is simply the path that the libRetro VM (Retroarch) stores the save under. The mGBA part is a function of the core being used to emulate -- there is no guarantee that it will match the emulator name used by Retrom, or IGDB, or any other source other than that version of that emulator core. In other words, Retrom has no control over nor pre-existing knowledge of that part of the path.

As far as Retrom is concerned, your save path from that example is /home/shellywell123/.local/share/server/saves/gameboy/pokemon/{something}. This provides no indication of what Retrom emulator the save belongs to.

What are your thoughts on including it with a warning

I am hesitant for two reasons:

  1. There will be an expectation to maintain this functionality, and I very much dislike introducing flaky/risky architecture into Retrom that could otherwise be avoided.
    1. Any new behavior that manages library paths will need to keep this in mind. Not only will rename operations need to rename files and DB entries, but now also save files (and anything that follows) needs to be changed.
    2. Modifying existing behavior that is related to library paths comes with the same risks
    3. Both of the above must be done atomically. If not done atomically and renaming the rom's filename succeeds, but updating the save's filename fails the user would need to manually adjust. This means implementation comes at a higher complexity cost.
  2. A naive user going and re-naming their files willy-nilly is not going to care about the fact that they were warned. If they boot up their game and are met with a 'New Game' screen instead of a 'Continue' screen, they are simply going to blame Retrom and lose faith in its ability to serve their purposes.

alternatively exploring the use of IGDB metadata, to keep things uniform

Unfortunately, not all users opt-in to use IGDB. This is why Retrom requires manual configuration of everything. As such, there is no well-defined spec that can be used, it is all user-defined on a case-by-case basis. Additionally, there is no real way to map IGDB platform to a given emulator.

tldr;
Save data is important and users will not like it being messed up/lost, even due to their own folly after being warned. For this reason I insist that any handling of save-data in Retrom is verifiably robust and safe. Unfortunately the state of Retrom's architecture is not quite mature enough to facilitate what you propose in a manner that I would be comfortable with. Yet! I have many WIP plans for improving the various systems at play in Retrom, and I think this could very possibly fit in as an option once this is done.


I am sorry that you went to all of this effort just to be met with a 'not quite yet'. This is why I generally urge contributors start a discussion prior to any implementation work. Let's put a pin in this, at least until the many in-progress refactors are done and Retrom's architecture has matured a bit more.

@JMBeresford
JMBeresford force-pushed the main branch 24 times, most recently from 2b76cc7 to 798c1b7 Compare February 8, 2026 00:58
@Shellywell123

Copy link
Copy Markdown
Author

I am sorry that you went to all of this effort just to be met with a 'not quite yet'. This is why I generally urge contributors start a discussion prior to any implementation work. Let's put a pin in this, at least until the many in-progress refactors are done and Retrom's architecture has matured a bit more.

No worries, I totally understand the hesitations and the answer given due to the current state of the project.
As a user myself, I love the project and want to find away to protect the game<->saves mapping before trusting the application with my save files.

I still think mimicking/duplicating the file path to a users rom, is the best way to map the two.
As a file path + file name will always be unique.

But happy to hear other suggestions :)

Alternatively we could incorporate a save file exporter tool, that retrieves all the files and dumps them back out in a sepcific format, but again this is not as clean as just storing the files in a set structure to begin with.

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.

3 participants