From b5dd60ca8812f550e8ea308952935ab13cf825ae Mon Sep 17 00:00:00 2001 From: markmental Date: Thu, 15 Oct 2026 15:47:09 -0400 Subject: [PATCH] 0.2.1 changes, add setup subcommand, improve UX for copy, list and install subcommands --- .gitignore | 1 + AGENTS.md | 8 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 21 ++- save-games/ISS Pro Evolution (USA).srm | Bin 131072 -> 0 bytes src/main.rs | 187 +++++++++++++++++++------ 7 files changed, 168 insertions(+), 53 deletions(-) delete mode 100644 save-games/ISS Pro Evolution (USA).srm 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 6ac8de0d1344972a237079ec1ff970ff0d28d5bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 131072 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, + }, /// 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" + ); + } }