0.2.1 changes, add setup subcommand, improve UX for copy, list and install subcommands

This commit is contained in:
markmental 2026-10-15 15:47:09 -04:00
commit b5dd60ca88
7 changed files with 168 additions and 53 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
target/
JAPS2ME/
memcardrex/
*-workspace/

View file

@ -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 <CARD.gme|CARD.srm>` — list saves as a Unicode table with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots.
- `list <CARD.gme|CARD.srm>...` — list one or more cards as separate Unicode tables with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots.
- `convert <INPUT.gme> <OUTPUT.srm>` — validate a DexDrive `.gme` file and write its raw payload as `.srm`.
- `import <CARD.srm> <SOURCE.gme> <SAVE-NAME>` — copy a named save from a `.gme` file into a `.srm` card.
- `delete <CARD.srm> <SLOT-OR-SAVE>` — delete a save from a `.srm` card by slot number or save name.
- `copy <SOURCE.srm> <DEST.srm|DEST-DIR>` — 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 <SOURCE.srm>` — 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

2
Cargo.lock generated
View file

@ -177,7 +177,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "japs1me"
version = "0.2.0"
version = "0.2.1"
dependencies = [
"anyhow",
"clap",

View file

@ -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"

View file

@ -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 <CARD.gme\|CARD.srm>` | List saves and free slots. |
| `japs1me list <CARD.gme\|CARD.srm>...` | List saves and free slots for one or more cards. |
| `japs1me convert <INPUT.gme> <OUTPUT.srm>` | Convert a DexDrive `.gme` file to RetroArch `.srm`. |
| `japs1me import <CARD.srm> <SOURCE.gme> <SAVE-NAME>` | Import a save from a `.gme` (or raw misnamed `.gme`) file into a `.srm` card. |
| `japs1me delete <CARD.srm> <SLOT-OR-SAVE>` | Delete a save from a `.srm` card by 1-based slot number or save name. |
| `japs1me copy <SOURCE.srm> <DEST.srm>` | Copy an emulator `.srm` into the workspace for JAPS1ME editing; bare source names resolve in the saves directory. |
| `japs1me copy <SOURCE.srm> <DEST.srm\|DEST-DIR>` | 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 <SOURCE.srm>` | 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 <dest> with <source>? (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 `<name>.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 `<name>.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.

View file

@ -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<String>,
},
/// 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<PathBuf> {
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<Option<String>> {
eprint!("{prompt}");
@ -365,6 +377,19 @@ fn copy_prefix_matches(prefix: &str, cards: &[PathBuf]) -> Vec<PathBuf> {
.collect()
}
/// Resolve a copy destination, using the source basename when `dest` is a directory.
fn resolve_copy_destination(dest: &str, source: &Path) -> Result<PathBuf> {
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"
);
}
}