diff --git a/.gitignore b/.gitignore index 7e91442..9dbca12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target/ JAPS2ME/ memcardrex/ +*-workspace/ diff --git a/AGENTS.md b/AGENTS.md index ffff3fe..959c0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,14 +6,16 @@ JAPS1ME (PS1 RetroArch Save Converter) is a Rust CLI and library for listing PlayStation 1 memory-card saves and converting DexDrive `.gme` files to RetroArch `.srm` format. Current CLI commands: -- `list ` — list saves as a Unicode table with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots. +- `list ...` — list one or more cards as separate Unicode tables with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots. - `convert ` — validate a DexDrive `.gme` file and write its raw payload as `.srm`. - `import ` — copy a named save from a `.gme` file into a `.srm` card. - `delete ` — delete a save from a `.srm` card by slot number or save name. +- `copy ` — copy a card from the configured saves directory into the workspace; directory destinations use the source filename. +- `setup` — configure the RetroArch saves directory used by `copy` and `install`. - `install ` — install a `.srm` card into the configured RetroArch saves directory. - `--version` / `-V` — print the package version and exit. -The current package version is `0.1.0`; keep the root package versions in `Cargo.toml` and `Cargo.lock` in sync. +The current package version is `0.2.1`; keep the root package versions in `Cargo.toml` and `Cargo.lock` in sync. The project is licensed under GPL-3.0-or-later. `memcardrex` is a technical reference for PS1 memory-card and DexDrive behavior; see its attribution in @@ -52,7 +54,7 @@ After changing code, rebuild the executable being invoked. For example, `cargo b - Never use the user's only save as a test target. Make a temporary copy under `/tmp/opencode/` for integration tests. - The convert command stages output in a temporary file, refuses to overwrite existing outputs, and only persists after validation. - The import command stages the updated card in a temporary file, re-validates it, and only then replaces the target. -- The delete command uses the same staged replace flow. The install command backs up the overwritten card before replacing it in place. +- The delete command uses the same staged replace flow. The install command backs up the overwritten card before replacing it in place, replacing an existing regular-file `.srm.japs1me-bak` with the latest prior card. - Recommend an independent backup before converting over anything important. ## Card invariants diff --git a/Cargo.lock b/Cargo.lock index 216b05e..c520565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,7 +177,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "japs1me" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0a07eff..a3f8c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "japs1me" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "JAPS1ME - Just A PS1 Memory Card Editor (DexDrive .gme to RetroArch .srm)" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 285ade8..065424e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ saves and cards. - Import a named save from a `.gme` file (or a raw card misnamed `.gme`) into a RetroArch `.srm` card, with duplicate and free-space checks. - Delete saves from a `.srm` card by slot number or save name. - Copy an emulator `.srm` out of the RetroArch saves directory into this workspace for JAPS1ME work (bare file name or prefix; asks before overwriting). +- Configure the RetroArch saves directory ahead of time with `setup`. - Install a `.srm` card into your RetroArch saves directory (remembers the directory, backs up the replaced card). -- Browse saves with slot numbers, save names, Shift-JIS game titles, regions, block counts, payload sizes, and free slots. +- Browse one or more cards with slot numbers, save names, Shift-JIS game titles, regions, block counts, payload sizes, and free slots. - Read-only `list`; `convert` validates before writing and never overwrites an existing output file. ## Build @@ -35,27 +36,32 @@ The executable is created at `target/release/japs1me`. | Command | Purpose | | --- | --- | | `japs1me --version` | Print the version (`-V` is also supported). | -| `japs1me list ` | List saves and free slots. | +| `japs1me list ...` | List saves and free slots for one or more cards. | | `japs1me convert ` | Convert a DexDrive `.gme` file to RetroArch `.srm`. | | `japs1me import ` | Import a save from a `.gme` (or raw misnamed `.gme`) file into a `.srm` card. | | `japs1me delete ` | Delete a save from a `.srm` card by 1-based slot number or save name. | -| `japs1me copy ` | Copy an emulator `.srm` into the workspace for JAPS1ME editing; bare source names resolve in the saves directory. | +| `japs1me copy ` | Copy an emulator `.srm` into the workspace for JAPS1ME editing; bare source names resolve in the saves directory. A directory destination keeps the source filename. | +| `japs1me setup` | Configure the RetroArch saves directory used by `copy` and `install`. | | `japs1me install ` | Install a `.srm` card into the configured RetroArch saves directory. | Examples: ```sh target/release/japs1me list save-games/pro-evolution-soccer-2.22507.gme +target/release/japs1me list save-games/*.gme target/release/japs1me convert save-games/pro-evolution-soccer-2.22507.gme pes2.srm target/release/japs1me import mysaves.srm save-games/pro-evolution-soccer-2.22507.gme BESLES-03946PES-OPT target/release/japs1me delete mysaves.srm BESLES-03946PES-OPT target/release/japs1me copy "ISS Pro Evolution (USA).srm" /tmp/work.srm +target/release/japs1me copy "ISS Pro Evolution (USA).srm" . +target/release/japs1me setup target/release/japs1me install mysaves.srm ``` ### `list` - Accepts `.gme` files and raw images (`.srm`, `.mcr`, …) detected by size and `MC` signature. +- Accepts one or more card paths; shell-expanded globs such as `japs1me list *.srm` list each card in its own labeled table. - Shows each save's 1-based slot, save name, game title (Shift-JIS decoded, falling back to the save name), region, block count, payload size, and active/deleted status. - The footer reports free slots out of 15. Directory checksum mismatches and orphaned links are reported as warnings on stderr. @@ -84,15 +90,21 @@ target/release/japs1me install mysaves.srm - Pull an emulator save out of the RetroArch saves directory into this workspace copy, so `list`/`import`/`delete` work happens here and the emulator original is never modified (raw `.srm`, 131072 bytes starting with `MC`). - A bare source file name (no `/`) that does not exist as given resolves inside the configured RetroArch saves directory (`./.japs1mesavedir` wins over the global config when both exist); paths with a separator or that exist literally are used as-is. An exact file-name match wins; otherwise a prefix match applies: no match errors out, one match is used, and several show a numbered picker. - An existing destination asks `Overwrite with ? (y/n)` on interactive terminals; only `y`/`yes` proceeds. Piped stdin fails instead of overwriting. Copying a file onto itself is refused. +- If the destination is `.` or an existing directory, the resolved source filename is used inside that directory; other destinations are treated as exact file paths. - The destination is staged in a temporary file and re-validated before it becomes visible. ### `install` - On first use (no saved directory configured), asks for your RetroArch PS1 core's saves directory and saves it as a single line (`$XDG_CONFIG_HOME` is honored when set; falls back to `./.japs1mesavedir` when no user config directory resolves, e.g. on Windows). A `./.japs1mesavedir` file in the invocation directory overrides the global config when both exist, so a workspace can point elsewhere. - When the source file name exactly matches a `.srm` in the saves directory, that card is inferred and the numbered picker is skipped. Otherwise scans the directory for `.srm` files and asks which one to overwrite (numbered, single attempt). A second strict `y`/`yes` confirmation follows; anything else cancels. -- Backs up the existing card to `.srm.japs1me-bak` first (refuses to proceed if that backup already exists), then replaces the target's contents in place so the filename still matches the game's `.cue` basename. +- Backs up the existing card to `.srm.japs1me-bak` first, replacing an existing regular-file backup with the card currently being overwritten, then replaces the target's contents in place so the filename still matches the game's `.cue` basename. - Prompts require an interactive terminal; piped stdin fails with an error instead. +### `setup` + +- Configure the RetroArch PS1 saves directory used by `copy` and `install` without starting an install. +- Reuses a valid configured directory; if the setting is missing or points to a nonexistent directory, prompts for a valid one and saves it using the same local/global config rules as `install`. + ## Safety - Back up saves before converting. @@ -149,4 +161,3 @@ JAPS1ME is licensed under the **GNU General Public License, version 3 or (at you ## Acknowledgements Thanks to [ShendoXT and the MemcardRex project](https://github.com/ShendoXT/memcardrex), an advanced PlayStation 1 memory-card editor. Its implementation provided a valuable technical reference for understanding the PS1 memory-card and DexDrive formats. - diff --git a/save-games/ISS Pro Evolution (USA).srm b/save-games/ISS Pro Evolution (USA).srm deleted file mode 100644 index 6ac8de0..0000000 Binary files a/save-games/ISS Pro Evolution (USA).srm and /dev/null differ diff --git a/src/main.rs b/src/main.rs index 70b7175..4782889 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,11 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// List saves on a .gme or raw (.srm/.mcr) card image. - List { card: String }, + /// List saves on one or more .gme or raw (.srm/.mcr) card images. + List { + #[arg(required = true, num_args = 1..)] + cards: Vec, + }, /// Convert a DexDrive .gme file to a RetroArch .srm file. Convert { input: String, output: String }, /// Import a save from a .gme file into a RetroArch .srm card. @@ -36,24 +39,35 @@ enum Command { Delete { card: String, target: String }, /// Copy an emulator .srm out of the RetroArch saves directory into this workspace for JAPS1ME work. Copy { source: String, dest: String }, + /// Configure the RetroArch saves directory used by copy and install. + Setup, /// Install a .srm card into the configured RetroArch saves directory. Install { source: String }, } fn main() -> Result<()> { match Cli::parse().command { - Command::List { card } => { - let bytes = std::fs::read(&card) - .with_context(|| format!("reading {}", Path::new(&card).display()))?; - let (raw, warnings) = load_raw(&bytes, &card)?; - for warning in warnings { - eprintln!("warning: {warning}"); + Command::List { cards } => { + let multiple_cards = cards.len() > 1; + for (index, card) in cards.iter().enumerate() { + let bytes = std::fs::read(card) + .with_context(|| format!("reading {}", Path::new(card).display()))?; + let (raw, warnings) = load_raw(&bytes, card)?; + for warning in warnings { + eprintln!("warning: {}: {warning}", Path::new(card).display()); + } + let summary = parse_raw(&raw)?; + for warning in &summary.warnings { + eprintln!("warning: {}: {warning}", Path::new(card).display()); + } + if multiple_cards { + if index > 0 { + println!(); + } + println!("== {} ==", Path::new(card).display()); + } + println!("{}", format_save_table(&summary.saves, summary.free_slots)); } - let summary = parse_raw(&raw)?; - for warning in &summary.warnings { - eprintln!("warning: {warning}"); - } - println!("{}", format_save_table(&summary.saves, summary.free_slots)); } Command::Convert { input, output } => { let bytes = std::fs::read(&input) @@ -183,7 +197,7 @@ fn main() -> Result<()> { for warning in &source_summary.warnings { eprintln!("warning: {warning}"); } - let dest_path = PathBuf::from(&dest); + let dest_path = resolve_copy_destination(&dest, &resolved)?; if same_file(&resolved, &dest_path) { bail!( "source and destination are the same file ({}); nothing changed", @@ -212,6 +226,10 @@ fn main() -> Result<()> { source_summary.saves.len() ); } + Command::Setup => { + let dir = ensure_saved_dir()?; + eprintln!("RetroArch saves directory: {}", dir.display()); + } Command::Install { source } => { let source_bytes = std::fs::read(&source) .with_context(|| format!("reading {}", Path::new(&source).display()))?; @@ -227,26 +245,7 @@ fn main() -> Result<()> { for warning in &source_summary.warnings { eprintln!("warning: {warning}"); } - let dir = match load_saved_dir()? { - Some(dir) if dir.is_dir() => dir, - _ => { - require_terminal( - "no RetroArch saves directory configured; run interactively to set one", - )?; - let answer = prompt_line("Enter your RetroArch PS1 saves directory: ")? - .ok_or_else(|| anyhow!("no directory given; nothing changed"))?; - if answer.trim().is_empty() { - bail!("no directory given; nothing changed"); - } - let dir = PathBuf::from(answer.trim()); - if !dir.is_dir() { - bail!("not a directory: '{}'; nothing changed", answer.trim()); - } - let saved = save_dir_config(&dir)?; - eprintln!("Saved saves directory to {}", saved.display()); - dir - } - }; + let dir = ensure_saved_dir()?; let cards = find_srm_files(&dir)?; if cards.is_empty() { bail!("no .srm files found in {}", dir.display()); @@ -290,15 +289,7 @@ fn main() -> Result<()> { bail!("install cancelled; '{}' unchanged", target.display()); } let backup = backup_path(&target); - if backup.symlink_metadata().is_ok() { - bail!( - "backup already exists: {}; move or delete it first", - backup.display() - ); - } - std::fs::copy(&target, &backup).with_context(|| { - format!("backing up {} to {}", target.display(), backup.display()) - })?; + backup_file_atomically(&target, &backup)?; replace_file_atomically(&target.to_string_lossy(), &source_bytes)?; let check = std::fs::read(&target).with_context(|| format!("reading {}", target.display()))?; @@ -322,6 +313,27 @@ fn require_terminal(message: &str) -> Result<()> { Ok(()) } +/// Return the configured saves directory, prompting and saving it when needed. +fn ensure_saved_dir() -> Result { + if let Some(dir) = load_saved_dir()?.filter(|dir| dir.is_dir()) { + return Ok(dir); + } + + require_terminal("no RetroArch saves directory configured; run interactively to set one")?; + let answer = prompt_line("Enter your RetroArch PS1 saves directory: ")? + .ok_or_else(|| anyhow!("no directory given; nothing changed"))?; + if answer.trim().is_empty() { + bail!("no directory given; nothing changed"); + } + let dir = PathBuf::from(answer.trim()); + if !dir.is_dir() { + bail!("not a directory: '{}'; nothing changed", answer.trim()); + } + let saved = save_dir_config(&dir)?; + eprintln!("Saved saves directory to {}", saved.display()); + Ok(dir) +} + /// Print `prompt` and read one line; `Ok(None)` on EOF. fn prompt_line(prompt: &str) -> Result> { eprint!("{prompt}"); @@ -365,6 +377,19 @@ fn copy_prefix_matches(prefix: &str, cards: &[PathBuf]) -> Vec { .collect() } +/// Resolve a copy destination, using the source basename when `dest` is a directory. +fn resolve_copy_destination(dest: &str, source: &Path) -> Result { + let dest_path = Path::new(dest); + if dest == "." || dest_path.is_dir() { + let file_name = source + .file_name() + .ok_or_else(|| anyhow!("copy source has no file name: {}", source.display()))?; + Ok(dest_path.join(file_name)) + } else { + Ok(dest_path.to_path_buf()) + } +} + /// Resolve a copy source, falling back to the saves dir for bare names. /// /// A source that exists as given or contains a path separator is used @@ -463,6 +488,26 @@ fn write_temp_then_persist(output: &Path, data: &[u8], context: &'static str) -> Ok(()) } +/// Back up a card, atomically replacing a prior regular-file backup if present. +fn backup_file_atomically(target: &Path, backup: &Path) -> Result<()> { + match backup.symlink_metadata() { + Ok(metadata) if !metadata.file_type().is_file() => { + bail!( + "backup path exists and is not a regular file: {}", + backup.display() + ); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).with_context(|| format!("checking {}", backup.display())), + } + + let contents = std::fs::read(target) + .with_context(|| format!("reading {} for backup", target.display()))?; + write_temp_then_persist(backup, &contents, "creating temporary backup file") + .with_context(|| format!("backing up {} to {}", target.display(), backup.display())) +} + #[cfg(test)] mod tests { use super::*; @@ -491,4 +536,60 @@ mod tests { assert!(copy_prefix_matches("GAME", &all).is_empty()); assert_eq!(copy_prefix_matches("game", &all).len(), 1); } + + #[test] + fn cli_list_accepts_one_or_more_cards() { + assert!(Cli::try_parse_from(["japs1me", "list", "one.srm"]).is_ok()); + assert!(Cli::try_parse_from(["japs1me", "list", "one.srm", "two.gme"]).is_ok()); + assert!(Cli::try_parse_from(["japs1me", "list"]).is_err()); + } + + #[test] + fn cli_setup_takes_no_arguments() { + assert!(Cli::try_parse_from(["japs1me", "setup"]).is_ok()); + assert!(Cli::try_parse_from(["japs1me", "setup", "extra"]).is_err()); + } + + #[test] + fn copy_destination_directory_uses_source_file_name() { + let source = Path::new("/saves/game.srm"); + let dir = tempfile::tempdir().expect("tempdir"); + assert_eq!( + resolve_copy_destination(&dir.path().to_string_lossy(), source).expect("resolves"), + dir.path().join("game.srm") + ); + assert_eq!( + resolve_copy_destination(".", source).expect("resolves"), + PathBuf::from("./game.srm") + ); + } + + #[test] + fn copy_destination_file_path_stays_exact() { + let source = Path::new("/saves/game.srm"); + assert_eq!( + resolve_copy_destination("/tmp/custom-name.srm", source).expect("resolves"), + PathBuf::from("/tmp/custom-name.srm") + ); + } + + #[test] + fn existing_backup_is_replaced_with_current_target() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("game.srm"); + let backup = backup_path(&target); + std::fs::write(&target, b"new previous card").expect("write target"); + std::fs::write(&backup, b"older backup").expect("write old backup"); + + backup_file_atomically(&target, &backup).expect("replaces backup"); + + assert_eq!( + std::fs::read(&backup).expect("read backup"), + b"new previous card" + ); + assert_eq!( + std::fs::read(&target).expect("read target"), + b"new previous card" + ); + } }