0.2.1 changes, add setup subcommand, improve UX for copy, list and install subcommands
This commit is contained in:
parent
d4af9f3ebc
commit
b5dd60ca88
7 changed files with 168 additions and 53 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
|||
target/
|
||||
JAPS2ME/
|
||||
memcardrex/
|
||||
*-workspace/
|
||||
|
|
|
|||
|
|
@ -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
2
Cargo.lock
generated
|
|
@ -177,7 +177,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|||
|
||||
[[package]]
|
||||
name = "japs1me"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
21
README.md
21
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 <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.
|
||||
|
||||
|
|
|
|||
Binary file not shown.
187
src/main.rs
187
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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue