Changes for v0.2, adds ability for import to tolerate mislabeled .gme files that are actually just bare ps1 MC images, as well as the copy command and other ease of use improvements for CLI
This commit is contained in:
parent
967624e2f6
commit
1473981a2e
9 changed files with 435 additions and 48 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -177,7 +177,7 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|||
|
||||
[[package]]
|
||||
name = "japs1me"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "japs1me"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "JAPS1ME - Just A PS1 Memory Card Editor (DexDrive .gme to RetroArch .srm)"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -13,8 +13,9 @@ saves and cards.
|
|||
## Features
|
||||
|
||||
- Convert DexDrive `.gme` files (including headerless/zeroed-header variants) to RetroArch `.srm` with validated stripping.
|
||||
- Import a named save from a `.gme` file into a RetroArch `.srm` card, with duplicate and free-space checks.
|
||||
- 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).
|
||||
- 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.
|
||||
- Read-only `list`; `convert` validates before writing and never overwrites an existing output file.
|
||||
|
|
@ -36,8 +37,9 @@ The executable is created at `target/release/japs1me`.
|
|||
| `japs1me --version` | Print the version (`-V` is also supported). |
|
||||
| `japs1me list <CARD.gme\|CARD.srm>` | List saves and free slots. |
|
||||
| `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` file into a `.srm` card. |
|
||||
| `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 install <SOURCE.srm>` | Install a `.srm` card into the configured RetroArch saves directory. |
|
||||
|
||||
Examples:
|
||||
|
|
@ -47,6 +49,7 @@ target/release/japs1me list save-games/pro-evolution-soccer-2.22507.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 install mysaves.srm
|
||||
```
|
||||
|
||||
|
|
@ -59,11 +62,12 @@ target/release/japs1me install mysaves.srm
|
|||
### `convert`
|
||||
|
||||
- Accepts DexDrive `.gme` input only (134976 bytes with an `MC` signature at offset 3904). Files with a missing/zeroed header are accepted with a warning.
|
||||
- A raw 131072-byte input is rejected with an “already a raw `.srm` image” error; copy or rename it instead of converting.
|
||||
- Writes the raw 131072-byte payload verbatim via a temporary file; existing output files are not overwritten.
|
||||
|
||||
### `import`
|
||||
|
||||
- Copies one save (matched by exact save name, e.g. `BESLES-03946PES-OPT`) from a `.gme` source into a raw `.srm` card, using the first free slots in slot order.
|
||||
- Copies one save (matched by exact save name, e.g. `BESLES-03946PES-OPT`) from a `.gme` source into a raw `.srm` card, using the first free slots in slot order. A raw 131072-byte source starting with `MC` is also accepted (with a warning) for `.gme` files that are actually misnamed raw images.
|
||||
- If the save already exists on the target, asks `Save <name> already exists on <card>, overwrite it with the one from <source>? (y/n)` on interactive terminals. Only `y`/`yes` proceeds; anything else cancels and leaves the card unchanged. The old save is freed first, so replacements with a different block count work.
|
||||
- When stdin is not a terminal, an existing save is a hard error instead of a prompt.
|
||||
- Link bytes are relinked to the new slots, the size field is recomputed, and touched directory checksums are regenerated. Identity bytes (region, product code, identifier, title, save data) are preserved verbatim; saves imported from a deleted source slot are normalized to active.
|
||||
|
|
@ -75,10 +79,17 @@ target/release/japs1me install mysaves.srm
|
|||
- The save's slots are reset to formatted with fresh checksums and zeroed data, freeing them for future imports.
|
||||
- `.srm` targets only; the updated card is staged, re-validated, and only then replaced.
|
||||
|
||||
### `copy`
|
||||
|
||||
- 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.
|
||||
- The destination is staged in a temporary file and re-validated before it becomes visible.
|
||||
|
||||
### `install`
|
||||
|
||||
- On first use (no `~/.config/japs1mesavedir`), asks for your RetroArch PS1 core's saves directory and saves it as a single line in that file (`$XDG_CONFIG_HOME` is honored when set).
|
||||
- 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.
|
||||
- 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.
|
||||
- Prompts require an interactive terminal; piped stdin fails with an error instead.
|
||||
|
||||
|
|
@ -102,7 +113,8 @@ manage cards differently:
|
|||
game, with the `.srm` basename matching the disc image. Whole-card
|
||||
`convert` and `install` are therefore the primary workflow here.
|
||||
- Importing individual saves out of a shared card image only makes sense from
|
||||
`.gme` DexDrive archives, so `import` is limited to `.gme` sources; there is
|
||||
`.gme` DexDrive archives (raw images misnamed `.gme` are also accepted as
|
||||
sources), so `import` is limited to `.gme`/raw sources; there is
|
||||
no single-save (`.mcs`/`.psx`) support.
|
||||
|
||||
## Scope compared with MemcardRex
|
||||
|
|
@ -112,7 +124,7 @@ importing rather than general memory-card editing:
|
|||
|
||||
- `list` covers saves and free slots; there is no per-frame browser.
|
||||
- `convert` handles DexDrive `.gme` only; there is no VGS/VMP/MCX support and no reverse conversion.
|
||||
- `import` copies whole saves from `.gme` into `.srm`; there is no single-save (`.mcs`/`.psx`) support and no import into `.gme`.
|
||||
- `import` copies whole saves from `.gme` (or misnamed raw) into `.srm`; there is no single-save (`.mcs`/`.psx`) support and no import into `.gme`.
|
||||
- There is no GUI, card formatting, comment editing, or hardware (DexDrive/serial) support. JAPS1ME validates directory checksums read-only and never recalculates them, except when relinking frames during imports and resetting them during deletions.
|
||||
|
||||
For details about the card formats, see the [technical format guide](docs/FORMATS.md). The [save-games collection](save-games/README.md) lists the saves shared in this repository.
|
||||
|
|
|
|||
BIN
save-games/ISS Pro Evolution (USA).srm
Normal file
BIN
save-games/ISS Pro Evolution (USA).srm
Normal file
Binary file not shown.
BIN
save-games/WWF SmackDown! (USA).srm
Normal file
BIN
save-games/WWF SmackDown! (USA).srm
Normal file
Binary file not shown.
BIN
save-games/iss-pro-evolution.31564.gme
Normal file
BIN
save-games/iss-pro-evolution.31564.gme
Normal file
Binary file not shown.
BIN
save-games/wwf-smackdown.188.gme
Normal file
BIN
save-games/wwf-smackdown.188.gme
Normal file
Binary file not shown.
224
src/install.rs
224
src/install.rs
|
|
@ -1,13 +1,17 @@
|
|||
//! RetroArch saves-directory configuration and `.srm` discovery for `install`.
|
||||
//!
|
||||
//! The directory is stored as a single line in `~/.config/japs1mesavedir`
|
||||
//! (`$XDG_CONFIG_HOME` is honored when set).
|
||||
//! The directory is stored as a single line in `./.japs1mesavedir` when
|
||||
//! present (a per-workspace override), else in `~/.config/japs1mesavedir`
|
||||
//! (`$XDG_CONFIG_HOME` is honored when set). A local file wins when both
|
||||
//! exist so a workspace can override the global default.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Config file name inside the user's config directory.
|
||||
pub const CONFIG_FILE_NAME: &str = "japs1mesavedir";
|
||||
/// Config file name for a per-workspace override in the invocation directory.
|
||||
pub const LOCAL_CONFIG_FILE_NAME: &str = ".japs1mesavedir";
|
||||
|
||||
/// Resolve the user config directory (`$XDG_CONFIG_HOME`, else `~/.config`).
|
||||
fn resolve_config_dir() -> Result<PathBuf> {
|
||||
|
|
@ -30,26 +34,70 @@ pub fn config_path() -> Result<PathBuf> {
|
|||
Ok(config_path_in(&resolve_config_dir()?))
|
||||
}
|
||||
|
||||
/// Load the saved RetroArch saves directory, if configured.
|
||||
/// Parse one saved-directory file's contents; blank counts as missing.
|
||||
fn parse_saved_dir_contents(contents: &str) -> Option<PathBuf> {
|
||||
let line = contents.lines().next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(line))
|
||||
}
|
||||
|
||||
/// Read a saved directory from an explicit file path.
|
||||
///
|
||||
/// Returns `Ok(None)` when the config file is missing or blank.
|
||||
fn load_saved_dir_in(config_dir: &Path) -> Result<Option<PathBuf>> {
|
||||
let path = config_path_in(config_dir);
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
/// Returns `Ok(None)` when the file is missing or blank.
|
||||
fn load_saved_dir_from(path: &Path) -> Result<Option<PathBuf>> {
|
||||
let contents = match std::fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
|
||||
};
|
||||
let line = contents.lines().next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(PathBuf::from(line)))
|
||||
Ok(parse_saved_dir_contents(&contents))
|
||||
}
|
||||
|
||||
/// Load the saved RetroArch saves directory, if configured.
|
||||
///
|
||||
/// Returns `Ok(None)` when the config file is missing or blank.
|
||||
fn load_saved_dir_in(config_dir: &Path) -> Result<Option<PathBuf>> {
|
||||
load_saved_dir_from(&config_path_in(config_dir))
|
||||
}
|
||||
|
||||
/// Local override path for an explicit base directory (testable core).
|
||||
fn local_config_path_in(base: &Path) -> PathBuf {
|
||||
base.join(LOCAL_CONFIG_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Load the workspace-local override from an explicit base directory.
|
||||
fn load_local_saved_dir_in(base: &Path) -> Result<Option<PathBuf>> {
|
||||
load_saved_dir_from(&local_config_path_in(base))
|
||||
}
|
||||
|
||||
/// Load the workspace-local override from the invocation directory.
|
||||
///
|
||||
/// Missing/unreadable working directories count as missing.
|
||||
fn load_local_saved_dir() -> Option<PathBuf> {
|
||||
let cwd = std::env::current_dir().ok()?;
|
||||
load_local_saved_dir_in(&cwd).ok()?
|
||||
}
|
||||
|
||||
/// Prefer the workspace-local override; the global default wins only when
|
||||
/// no local file is configured.
|
||||
fn pick_saved_dir(local: Option<PathBuf>, user: Option<PathBuf>) -> Option<PathBuf> {
|
||||
local.or(user)
|
||||
}
|
||||
|
||||
/// Load the saved RetroArch saves directory for this user.
|
||||
///
|
||||
/// `./.japs1mesavedir` wins over the user config so a workspace can override
|
||||
/// the default. Unresolvable user config directories (e.g. `HOME` unset on
|
||||
/// Windows) fall through to the local file instead of erroring.
|
||||
pub fn load_saved_dir() -> Result<Option<PathBuf>> {
|
||||
load_saved_dir_in(&resolve_config_dir()?)
|
||||
let local = load_local_saved_dir();
|
||||
let user = match resolve_config_dir() {
|
||||
Ok(dir) => load_saved_dir_in(&dir)?,
|
||||
Err(_) => None,
|
||||
};
|
||||
Ok(pick_saved_dir(local, user))
|
||||
}
|
||||
|
||||
/// Persist the RetroArch saves directory, creating the config directory.
|
||||
|
|
@ -63,8 +111,25 @@ fn save_dir_config_in(config_dir: &Path, dir: &Path) -> Result<PathBuf> {
|
|||
}
|
||||
|
||||
/// Persist the RetroArch saves directory for this user.
|
||||
///
|
||||
/// Falls back to `./.japs1mesavedir` when no user config directory resolves
|
||||
/// (e.g. `HOME`/`XDG_CONFIG_HOME` unset on Windows).
|
||||
pub fn save_dir_config(dir: &Path) -> Result<PathBuf> {
|
||||
save_dir_config_in(&resolve_config_dir()?, dir)
|
||||
match resolve_config_dir() {
|
||||
Ok(config_dir) => save_dir_config_in(&config_dir, dir),
|
||||
Err(_) => save_dir_config_local_in(
|
||||
&std::env::current_dir().context("no working directory")?,
|
||||
dir,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the saves directory as a workspace-local override (testable core).
|
||||
fn save_dir_config_local_in(base: &Path, dir: &Path) -> Result<PathBuf> {
|
||||
let path = local_config_path_in(base);
|
||||
std::fs::write(&path, format!("{}\n", dir.display()))
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Find `.srm` files (case-insensitive) directly inside `dir`, sorted by name.
|
||||
|
|
@ -88,6 +153,36 @@ pub fn find_srm_files(dir: &Path) -> Result<Vec<PathBuf>> {
|
|||
Ok(cards)
|
||||
}
|
||||
|
||||
/// Find the card whose file name exactly matches the source file name.
|
||||
///
|
||||
/// Returns the matching card path, if any. Comparison is case-sensitive so the
|
||||
/// inferred target still matches the game's basename exactly on case-sensitive
|
||||
/// filesystems. At most one entry in a directory can match.
|
||||
pub fn find_match_by_name(source: &Path, cards: &[PathBuf]) -> Option<PathBuf> {
|
||||
let source_name = source.file_name()?;
|
||||
cards
|
||||
.iter()
|
||||
.find(|card| card.file_name().is_some_and(|name| name == source_name))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Resolve a copy source: bare file names fall back to the saves directory.
|
||||
///
|
||||
/// A source that exists as given or contains a path separator is used
|
||||
/// literally so files in the current directory are never shadowed. Otherwise,
|
||||
/// when a saves directory is configured, the bare name is joined onto it.
|
||||
pub fn resolve_in_saves_dir(source: &str, saved_dir: Option<&Path>) -> PathBuf {
|
||||
let path = Path::new(source);
|
||||
let has_separator = source.contains('/') || source.contains('\\');
|
||||
if has_separator || path.symlink_metadata().is_ok() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
match saved_dir {
|
||||
Some(dir) => dir.join(path),
|
||||
None => path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a 1-based numbered selection into a 0-based index.
|
||||
pub fn parse_selection(input: &str, count: usize) -> Result<usize> {
|
||||
let number: usize = input
|
||||
|
|
@ -167,4 +262,105 @@ mod tests {
|
|||
let backup = backup_path(Path::new("/saves/game.srm"));
|
||||
assert_eq!(backup, PathBuf::from("/saves/game.srm.japs1me-bak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_by_name_finds_exact_hit() {
|
||||
let cards = vec![
|
||||
PathBuf::from("/saves/a.srm"),
|
||||
PathBuf::from("/saves/game.srm"),
|
||||
PathBuf::from("/saves/b.srm"),
|
||||
];
|
||||
assert_eq!(
|
||||
find_match_by_name(Path::new("/tmp/game.srm"), &cards),
|
||||
Some(PathBuf::from("/saves/game.srm"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_by_name_misses_and_is_case_sensitive() {
|
||||
let cards = vec![PathBuf::from("/saves/game.srm")];
|
||||
assert_eq!(
|
||||
find_match_by_name(Path::new("/tmp/other.srm"), &cards),
|
||||
None
|
||||
);
|
||||
assert_eq!(find_match_by_name(Path::new("/tmp/GAME.SRM"), &cards), None);
|
||||
assert_eq!(find_match_by_name(Path::new("/tmp/game.srm"), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_source_bare_name_falls_back_to_saves_dir() {
|
||||
assert_eq!(
|
||||
resolve_in_saves_dir("game.srm", Some(Path::new("/saves"))),
|
||||
PathBuf::from("/saves/game.srm")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_in_saves_dir("game.srm", None),
|
||||
PathBuf::from("game.srm")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_source_with_separator_stays_literal() {
|
||||
assert_eq!(
|
||||
resolve_in_saves_dir("sub/game.srm", Some(Path::new("/saves"))),
|
||||
PathBuf::from("sub/game.srm")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_in_saves_dir("/abs/game.srm", Some(Path::new("/saves"))),
|
||||
PathBuf::from("/abs/game.srm")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_source_absolute_path_stays_literal() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let abs_file = dir.path().join("local.srm");
|
||||
std::fs::write(&abs_file, [0u8; 1]).expect("write");
|
||||
let saved = tempfile::tempdir().expect("tempdir");
|
||||
let abs = abs_file.to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
resolve_in_saves_dir(&abs, Some(saved.path())),
|
||||
PathBuf::from(&abs)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_override_loads_and_blank_counts_as_missing() {
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
assert_eq!(load_local_saved_dir_in(base.path()).expect("loads"), None);
|
||||
std::fs::write(local_config_path_in(base.path()), "/saves/local\n").expect("write");
|
||||
assert_eq!(
|
||||
load_local_saved_dir_in(base.path()).expect("loads"),
|
||||
Some(PathBuf::from("/saves/local"))
|
||||
);
|
||||
std::fs::write(local_config_path_in(base.path()), "\n").expect("write");
|
||||
assert_eq!(load_local_saved_dir_in(base.path()).expect("loads"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_override_wins_over_user_config() {
|
||||
assert_eq!(
|
||||
pick_saved_dir(
|
||||
Some(PathBuf::from("/saves/local")),
|
||||
Some(PathBuf::from("/saves/user"))
|
||||
),
|
||||
Some(PathBuf::from("/saves/local"))
|
||||
);
|
||||
assert_eq!(
|
||||
pick_saved_dir(None, Some(PathBuf::from("/saves/user"))),
|
||||
Some(PathBuf::from("/saves/user"))
|
||||
);
|
||||
assert_eq!(pick_saved_dir(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_save_roundtrip() {
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let saved = save_dir_config_local_in(base.path(), Path::new("/saves/ws")).expect("saves");
|
||||
assert_eq!(saved, local_config_path_in(base.path()));
|
||||
assert_eq!(
|
||||
load_local_saved_dir_in(base.path()).expect("loads"),
|
||||
Some(PathBuf::from("/saves/ws"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
229
src/main.rs
229
src/main.rs
|
|
@ -2,7 +2,8 @@ use anyhow::{anyhow, bail, Context, Result};
|
|||
use clap::{Parser, Subcommand};
|
||||
use japs1me::gme::{parse_gme, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
|
||||
use japs1me::install::{
|
||||
backup_path, find_srm_files, load_saved_dir, parse_selection, save_dir_config,
|
||||
backup_path, find_match_by_name, find_srm_files, load_saved_dir, parse_selection,
|
||||
resolve_in_saves_dir, save_dir_config,
|
||||
};
|
||||
use japs1me::{delete_save, format_save_table, import_save, import_save_overwrite, parse_raw};
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
|
@ -33,6 +34,8 @@ enum Command {
|
|||
},
|
||||
/// Delete a save from a RetroArch .srm card by slot number or save name.
|
||||
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 },
|
||||
/// Install a .srm card into the configured RetroArch saves directory.
|
||||
Install { source: String },
|
||||
}
|
||||
|
|
@ -55,6 +58,11 @@ fn main() -> Result<()> {
|
|||
Command::Convert { input, output } => {
|
||||
let bytes = std::fs::read(&input)
|
||||
.with_context(|| format!("reading {}", Path::new(&input).display()))?;
|
||||
if bytes.len() == RAW_CARD_LEN && bytes[0] == b'M' && bytes[1] == b'C' {
|
||||
bail!(
|
||||
"input '{input}' is already a raw .srm image ({RAW_CARD_LEN} bytes); copy or rename it instead of converting"
|
||||
);
|
||||
}
|
||||
if bytes.len() != GME_FILE_LEN {
|
||||
bail!(
|
||||
"not a DexDrive .gme file: expected {GME_FILE_LEN} bytes, found {}",
|
||||
|
|
@ -80,16 +88,21 @@ fn main() -> Result<()> {
|
|||
Command::Import { card, source, name } => {
|
||||
let source_bytes = std::fs::read(&source)
|
||||
.with_context(|| format!("reading {}", Path::new(&source).display()))?;
|
||||
if source_bytes.len() != GME_FILE_LEN {
|
||||
bail!(
|
||||
"source must be a DexDrive .gme file: expected {GME_FILE_LEN} bytes, found {}",
|
||||
source_bytes.len()
|
||||
);
|
||||
}
|
||||
let (source_card, warnings) = parse_gme(&source_bytes)?;
|
||||
let (source_raw, warnings) = load_raw(&source_bytes, &source).map_err(|_| {
|
||||
anyhow!(
|
||||
"source must be a DexDrive .gme file ({GME_FILE_LEN} bytes) or a raw .srm image ({RAW_CARD_LEN} bytes starting with MC): found {} bytes in '{}'",
|
||||
source_bytes.len(),
|
||||
source
|
||||
)
|
||||
})?;
|
||||
for warning in warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
if source_bytes.len() == RAW_CARD_LEN {
|
||||
eprintln!(
|
||||
"warning: source '{source}' is raw-sized; treating it as a raw .srm image despite the .gme extension"
|
||||
);
|
||||
}
|
||||
let target_bytes = std::fs::read(&card)
|
||||
.with_context(|| format!("reading {}", Path::new(&card).display()))?;
|
||||
if target_bytes.len() != RAW_CARD_LEN {
|
||||
|
|
@ -112,9 +125,9 @@ fn main() -> Result<()> {
|
|||
))? {
|
||||
bail!("import cancelled; '{card}' unchanged");
|
||||
}
|
||||
import_save_overwrite(&target_bytes, &source_card.raw, &name)?
|
||||
import_save_overwrite(&target_bytes, &source_raw, &name)?
|
||||
} else {
|
||||
import_save(&target_bytes, &source_card.raw, &name)?
|
||||
import_save(&target_bytes, &source_raw, &name)?
|
||||
};
|
||||
let summary = parse_raw(&new_raw)?;
|
||||
for warning in &summary.warnings {
|
||||
|
|
@ -154,6 +167,51 @@ fn main() -> Result<()> {
|
|||
report.save_name, report.blocks, summary.free_slots
|
||||
);
|
||||
}
|
||||
Command::Copy { source, dest } => {
|
||||
let resolved = resolve_copy_source(&source)?;
|
||||
let source_bytes = std::fs::read(&resolved)
|
||||
.with_context(|| format!("reading {}", resolved.display()))?;
|
||||
if source_bytes.len() != RAW_CARD_LEN
|
||||
|| source_bytes[0] != b'M'
|
||||
|| source_bytes[1] != b'C'
|
||||
{
|
||||
bail!(
|
||||
"copy source must be a raw .srm image ({RAW_CARD_LEN} bytes starting with MC)"
|
||||
);
|
||||
}
|
||||
let source_summary = parse_raw(&source_bytes)?;
|
||||
for warning in &source_summary.warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
let dest_path = PathBuf::from(&dest);
|
||||
if same_file(&resolved, &dest_path) {
|
||||
bail!(
|
||||
"source and destination are the same file ({}); nothing changed",
|
||||
dest_path.display()
|
||||
);
|
||||
}
|
||||
if dest_path.symlink_metadata().is_ok() {
|
||||
require_terminal("cannot confirm overwrite without a terminal")?;
|
||||
if !prompt_yes(&format!(
|
||||
"Overwrite {} with {}? (y/n) ",
|
||||
dest_path.display(),
|
||||
resolved.display()
|
||||
))? {
|
||||
bail!("copy cancelled; '{}' unchanged", dest_path.display());
|
||||
}
|
||||
}
|
||||
write_temp_then_persist(&dest_path, &source_bytes, "creating temporary copy file")
|
||||
.with_context(|| format!("writing {}", dest_path.display()))?;
|
||||
let check = std::fs::read(&dest_path)
|
||||
.with_context(|| format!("reading {}", dest_path.display()))?;
|
||||
parse_raw(&check).with_context(|| format!("verifying {}", dest_path.display()))?;
|
||||
eprintln!(
|
||||
"Copied {} to {} ({} save(s))",
|
||||
resolved.display(),
|
||||
dest_path.display(),
|
||||
source_summary.saves.len()
|
||||
);
|
||||
}
|
||||
Command::Install { source } => {
|
||||
let source_bytes = std::fs::read(&source)
|
||||
.with_context(|| format!("reading {}", Path::new(&source).display()))?;
|
||||
|
|
@ -193,19 +251,37 @@ fn main() -> Result<()> {
|
|||
if cards.is_empty() {
|
||||
bail!("no .srm files found in {}", dir.display());
|
||||
}
|
||||
for (index, card) in cards.iter().enumerate() {
|
||||
let name = card
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| card.as_os_str().to_string_lossy());
|
||||
eprintln!(" {}: {name}", index + 1);
|
||||
let target: PathBuf = match find_match_by_name(Path::new(&source), &cards) {
|
||||
Some(matched) => {
|
||||
eprintln!(
|
||||
"Matched {} by file name; skipping memory-card picker",
|
||||
matched.display()
|
||||
);
|
||||
matched
|
||||
}
|
||||
None => {
|
||||
for (index, card) in cards.iter().enumerate() {
|
||||
let name = card
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| card.as_os_str().to_string_lossy());
|
||||
eprintln!(" {}: {name}", index + 1);
|
||||
}
|
||||
require_terminal("cannot choose a memory card without a terminal")?;
|
||||
let choice = prompt_line("Which memory card to overwrite? (number) ")?
|
||||
.ok_or_else(|| anyhow!("no selection given; nothing changed"))?;
|
||||
let index = parse_selection(&choice, cards.len())
|
||||
.map_err(|error| anyhow!("{error}; nothing changed"))?;
|
||||
cards[index].clone()
|
||||
}
|
||||
};
|
||||
if same_file(&PathBuf::from(&source), &target) {
|
||||
bail!(
|
||||
"source and target are the same file ({}); nothing changed",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
require_terminal("cannot choose a memory card without a terminal")?;
|
||||
let choice = prompt_line("Which memory card to overwrite? (number) ")?
|
||||
.ok_or_else(|| anyhow!("no selection given; nothing changed"))?;
|
||||
let index = parse_selection(&choice, cards.len())
|
||||
.map_err(|error| anyhow!("{error}; nothing changed"))?;
|
||||
let target = &cards[index];
|
||||
require_terminal("cannot confirm install without a terminal")?;
|
||||
if !prompt_yes(&format!(
|
||||
"Overwrite {} with {}? (y/n) ",
|
||||
target.display(),
|
||||
|
|
@ -213,19 +289,19 @@ fn main() -> Result<()> {
|
|||
))? {
|
||||
bail!("install cancelled; '{}' unchanged", target.display());
|
||||
}
|
||||
let backup = backup_path(target);
|
||||
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(|| {
|
||||
std::fs::copy(&target, &backup).with_context(|| {
|
||||
format!("backing up {} to {}", target.display(), backup.display())
|
||||
})?;
|
||||
replace_file_atomically(&target.to_string_lossy(), &source_bytes)?;
|
||||
let check =
|
||||
std::fs::read(target).with_context(|| format!("reading {}", target.display()))?;
|
||||
std::fs::read(&target).with_context(|| format!("reading {}", target.display()))?;
|
||||
parse_raw(&check).with_context(|| format!("verifying {}", target.display()))?;
|
||||
eprintln!(
|
||||
"Installed {} as {} (backup: {})",
|
||||
|
|
@ -266,6 +342,79 @@ fn prompt_yes(prompt: &str) -> Result<bool> {
|
|||
.is_some_and(|answer| matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")))
|
||||
}
|
||||
|
||||
/// True when both paths resolve to the same file on disk.
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cards whose file name starts with `prefix` (case-sensitive), in order.
|
||||
fn copy_prefix_matches(prefix: &str, cards: &[PathBuf]) -> Vec<PathBuf> {
|
||||
cards
|
||||
.iter()
|
||||
.filter(|card| {
|
||||
card.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with(prefix))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// literally. Otherwise a bare name first tries an exact file-name match in
|
||||
/// the saves directory, then a prefix match: none errors out, one is used,
|
||||
/// and several offer a numbered picker.
|
||||
fn resolve_copy_source(source: &str) -> Result<PathBuf> {
|
||||
let literal = Path::new(source);
|
||||
if literal.symlink_metadata().is_ok() || source.contains('/') || source.contains('\\') {
|
||||
return Ok(literal.to_path_buf());
|
||||
}
|
||||
let saved = load_saved_dir()?;
|
||||
let Some(dir) = saved.filter(|dir| dir.is_dir()) else {
|
||||
return Ok(resolve_in_saves_dir(source, None));
|
||||
};
|
||||
let cards = find_srm_files(&dir)?;
|
||||
if let Some(exact) = cards
|
||||
.iter()
|
||||
.find(|card| card.file_name().is_some_and(|name| name == source))
|
||||
{
|
||||
return Ok(exact.clone());
|
||||
}
|
||||
let matches = copy_prefix_matches(source, &cards);
|
||||
match matches.len() {
|
||||
0 => bail!(
|
||||
"no .srm files in {} starting with '{source}'",
|
||||
dir.display()
|
||||
),
|
||||
1 => {
|
||||
eprintln!("Matched {} by prefix", matches[0].display());
|
||||
Ok(matches[0].clone())
|
||||
}
|
||||
_ => {
|
||||
for (index, card) in matches.iter().enumerate() {
|
||||
let name = card
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| card.as_os_str().to_string_lossy());
|
||||
eprintln!(" {}: {name}", index + 1);
|
||||
}
|
||||
require_terminal("cannot choose a save without a terminal")?;
|
||||
let choice = prompt_line("Which save to copy? (number) ")?
|
||||
.ok_or_else(|| anyhow!("no selection given; nothing changed"))?;
|
||||
let index = parse_selection(&choice, matches.len())
|
||||
.map_err(|error| anyhow!("{error}; nothing changed"))?;
|
||||
Ok(matches[index].clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a raw card image from either `.gme` (stripped) or raw (`.srm`/`.mcr`) bytes.
|
||||
fn load_raw(bytes: &[u8], name: &str) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
if bytes.len() == GME_FILE_LEN
|
||||
|
|
@ -313,3 +462,33 @@ fn write_temp_then_persist(output: &Path, data: &[u8], context: &'static str) ->
|
|||
.with_context(|| format!("writing {}", output.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cards(names: &[&str]) -> Vec<PathBuf> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| PathBuf::from(format!("/saves/{name}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_finds_single_and_multiple() {
|
||||
let all = cards(&["a.srm", "ab.srm", "b.srm"]);
|
||||
assert_eq!(
|
||||
copy_prefix_matches("ab", &all),
|
||||
vec![PathBuf::from("/saves/ab.srm")]
|
||||
);
|
||||
assert_eq!(copy_prefix_matches("a", &all).len(), 2);
|
||||
assert!(copy_prefix_matches("z", &all).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_is_case_sensitive() {
|
||||
let all = cards(&["game.srm"]);
|
||||
assert!(copy_prefix_matches("GAME", &all).is_empty());
|
||||
assert_eq!(copy_prefix_matches("game", &all).len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue