Initial Commit
This commit is contained in:
commit
23cd7065fe
16 changed files with 1506 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
target/
|
||||
JAPS2ME/
|
||||
memcardrex/
|
||||
Pro*.srm
|
||||
66
AGENTS.md
Normal file
66
AGENTS.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# Agent and Contributor Guide
|
||||
|
||||
## Project overview
|
||||
|
||||
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.
|
||||
- `convert <INPUT.gme> <OUTPUT.srm>` — validate a DexDrive `.gme` file and write its raw payload as `.srm`.
|
||||
- `--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 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
|
||||
`README.md`. The `JAPS2ME/` and `memcardrex/` directories are reference
|
||||
material, not part of the build.
|
||||
|
||||
## Repository map
|
||||
|
||||
- `src/main.rs` — Clap CLI, command dispatch, `.gme`/raw input detection, and atomic output writes.
|
||||
- `src/commands/list.rs` — Unicode table rendering and byte-size formatting for `list`.
|
||||
- `src/gme.rs` — DexDrive `.gme` header parsing, headerless fallback, and comment decoding.
|
||||
- `src/ps1/card.rs` — raw card parsing, slot/link chains, region names, XOR warnings, and save summaries.
|
||||
- `src/ps1/title.rs` — Shift-JIS save-title decoding.
|
||||
- `src/lib.rs` — crate modules and public exports.
|
||||
- `save-games/README.md` — inventory and contribution guidance for community-shared `.gme` saves in `save-games/`.
|
||||
- `save-games/` — curated save containers, including the `pro-evolution-soccer-2.22507.gme` fixture used by `include_bytes!` tests; preserve these user-provided files and do not use them as writable integration-test targets.
|
||||
|
||||
## Build and checks
|
||||
|
||||
Run these from the repository root:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
After changing code, rebuild the executable being invoked. For example, `cargo build` refreshes `target/debug/japs1me`; `cargo build --release` refreshes `target/release/japs1me`. A stale binary can continue exhibiting behavior fixed in the source.
|
||||
|
||||
## Memory-card safety
|
||||
|
||||
- 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.
|
||||
- Recommend an independent backup before converting over anything important.
|
||||
|
||||
## Card invariants
|
||||
|
||||
- A raw card image is exactly 131072 bytes starting with `MC`. A `.gme` file is a 3904-byte header plus the raw image (134976 bytes total).
|
||||
- Directory frames are 128 bytes at raw offset `128 + slot * 128`; save data blocks are 8192 bytes at raw offset `8192 + slot * 8192`.
|
||||
- Directory byte 127 is the XOR of bytes `0..127`. JAPS1ME verifies checksums read-only and reports mismatches as warnings; it never recalculates them.
|
||||
- A save is an initial slot (`0x51`/`0xA1`) plus its link chain via the next-pointer at frame byte 8 (`0xFF` terminates). Links unreachable from any initial slot are orphaned and counted as free, matching MemcardRex.
|
||||
- Save names are `region + product code + identifier` (frame bytes 10..30, NUL-trimmed). Regions `BA`/`BE`/`BI` display as America/Europe/Japan.
|
||||
- Save titles are 64-byte Shift-JIS fields at data offset 4, cut at the first even-index NUL; `list` falls back to the save name when no usable title exists.
|
||||
- `list` summaries count saves in slot order; sizes are `blocks * 8192` payload bytes. Free slots are formatted frames plus orphaned links.
|
||||
|
||||
## Change guidelines
|
||||
|
||||
- Keep parsing separate from output writing; both input kinds should produce validated raw bytes before any write.
|
||||
- Prefer focused tests for parser boundaries, link invariants, and refused overwrites.
|
||||
- Do not weaken length, `MC` signature, or checksum checks to accept malformed input without a documented compatibility reason and a regression test. The headerless-`.gme` fallback is the documented exception.
|
||||
- Save files in `save-games/` are separate assets, not automatically covered by the project's GPL license. Do not claim redistribution rights or assign a license without source/permission information; follow the contribution guidance in that directory's README.
|
||||
- Update `README.md` when user-facing commands, supported formats, build steps, or safety behavior change.
|
||||
335
Cargo.lock
generated
Normal file
335
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "japs1me"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"encoding_rs",
|
||||
"tempfile",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "japs1me"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "JAPS1ME - PS1 RetroArch Save Converter (DexDrive .gme to RetroArch .srm)"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
encoding_rs = "0.8"
|
||||
unicode-width = "0.2"
|
||||
tempfile = "3"
|
||||
|
||||
[[bin]]
|
||||
name = "japs1me"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "japs1me"
|
||||
path = "src/lib.rs"
|
||||
83
README.md
Normal file
83
README.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# JAPS1ME
|
||||
|
||||
**JAPS1ME - PS1 RetroArch Save Converter** is a command-line tool for converting
|
||||
DexDrive PlayStation 1 memory-card images (`.gme`) to RetroArch memory-card
|
||||
format (`.srm`). It also lists the saves on `.gme` and raw (`.srm`/`.mcr`)
|
||||
card images.
|
||||
|
||||
## Features
|
||||
|
||||
- Convert DexDrive `.gme` files (including headerless/zeroed-header variants) to RetroArch `.srm` with validated stripping.
|
||||
- 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.
|
||||
|
||||
## Build
|
||||
|
||||
You need a Rust toolchain with Cargo installed.
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The executable is created at `target/release/japs1me`.
|
||||
|
||||
## Usage
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `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`. |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
### `list`
|
||||
|
||||
- Accepts `.gme` files and raw images (`.srm`, `.mcr`, …) detected by size and `MC` signature.
|
||||
- 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.
|
||||
|
||||
### `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.
|
||||
- Writes the raw 131072-byte payload verbatim via a temporary file; existing output files are not overwritten.
|
||||
|
||||
## Safety
|
||||
|
||||
- Back up saves before converting.
|
||||
- `convert` refuses to overwrite; delete or rename a previous `.srm` first if you intend to replace it.
|
||||
- Avoid testing against your only copy of a save; the fixture in `save-games/` is read-only test input.
|
||||
|
||||
## Scope compared with MemcardRex
|
||||
|
||||
JAPS1ME focuses on `.gme` → `.srm` conversion and save listing 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.
|
||||
- There is no GUI, save import/export of single saves (`.mcs`/`.psx`), deletion, formatting, comment editing, or hardware (DexDrive/serial) support. JAPS1ME validates directory checksums read-only and never recalculates them.
|
||||
|
||||
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.
|
||||
|
||||
## Tests
|
||||
|
||||
Run formatting, tests, lint checks, and a release build from the repository root:
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
JAPS1ME is licensed under the **GNU General Public License, version 3 or (at your option) any later version** (SPDX: `GPL-3.0-or-later`). This project license does not automatically apply to the individual save files in `save-games/`; see that directory's README for contribution and attribution guidance.
|
||||
|
||||
## 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.
|
||||
76
docs/FORMATS.md
Normal file
76
docs/FORMATS.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# PS1 Save and Memory-Card Formats
|
||||
|
||||
This guide describes the formats JAPS1ME currently reads or writes. The notes
|
||||
were informed by the [MemcardRex project](https://github.com/ShendoXT/memcardrex),
|
||||
which is also credited in the main README. Corresponding JAPS1ME
|
||||
implementations: `src/gme.rs` and `src/ps1/`.
|
||||
|
||||
## DexDrive `.gme`
|
||||
|
||||
A `.gme` file is a 3904-byte header followed by a 131072-byte raw card image
|
||||
(134976 bytes total). JAPS1ME identifies the format by length plus the `MC`
|
||||
signature at offset 3904.
|
||||
|
||||
### Header
|
||||
|
||||
| Offset | Size | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| `0x00` | 11 | Magic: `123-456-STD` (canonical files) |
|
||||
| `0x12`–`0x15` | — | Version/status bytes written by MemcardRex (`0x01`, `0x01`/`M`) |
|
||||
| `0x16`–`0x25` | 15 | Per-slot type bytes (copies of each directory frame's type) |
|
||||
| `0x26`–`0x35` | 15 | Per-slot next-pointer bytes |
|
||||
| `0x40`–`0xF3F` | 15 × 256 | Per-slot comments (system-codepage text, NUL-terminated) |
|
||||
|
||||
Some files in the wild have a zeroed or otherwise non-canonical header. Like
|
||||
MemcardRex, JAPS1ME accepts those when the size is 134976 and bytes at 3904
|
||||
are `MC`, and reports a `missing DexDrive header magic` warning. Comments are
|
||||
decoded as Windows-1252 and are informational only; `convert` never modifies
|
||||
them.
|
||||
|
||||
## Raw card image (`.srm`/`.mcr`)
|
||||
|
||||
The raw image is 131072 bytes (1024 frames of 128 bytes). RetroArch `.srm`,
|
||||
ePSXe `.mcr`, and similar formats share this layout; only the file extension
|
||||
differs. `convert` writes exactly the `.gme` payload bytes `3904..` with no
|
||||
modification, so directory checksums and save data are preserved verbatim.
|
||||
|
||||
### Geometry
|
||||
|
||||
| Raw offset | Size | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| `0x00` | 128 | Card header frame (`MC` magic) |
|
||||
| `0x80`–`0x7FF` | 15 × 128 | Directory frames (slots 1–15) |
|
||||
| `0x2000`– | 15 × 8192 | Save data blocks |
|
||||
|
||||
### Directory frames
|
||||
|
||||
Each 128-byte frame describes one slot. Relevant fields:
|
||||
|
||||
| Offset | Size | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| `0x00` | 1 | Slot type: `0xA0` formatted, `0x51` initial, `0x52`/`0x53` middle/end link, `0xA1`–`0xA3` deleted variants |
|
||||
| `0x04` | 3 | Payload size, little-endian |
|
||||
| `0x08` | 1 | Next-slot pointer (`0xFF` = end of chain) |
|
||||
| `0x0A` | 2 | Region (`BA` America, `BE` Europe, `BI` Japan) |
|
||||
| `0x0C` | 10 | Product code |
|
||||
| `0x16` | 8 | Save identifier |
|
||||
| `0x7F` | 1 | XOR checksum over bytes `0x00..0x7F` |
|
||||
|
||||
A save is an initial slot plus its link chain (up to 15 slots). Links not
|
||||
reachable from any initial slot are orphaned; `list` reports them and counts
|
||||
them as free, matching MemcardRex's broken-link handling.
|
||||
|
||||
### Save titles
|
||||
|
||||
Each data block carries a 64-byte Shift-JIS title at block offset 4,
|
||||
NUL-terminated on an even byte boundary. JAPS1ME decodes it with a
|
||||
malformed-sequence replacement and falls back to the save name
|
||||
(`region + product code + identifier`) when no usable title exists.
|
||||
|
||||
## References
|
||||
|
||||
- [MemcardRex](https://github.com/ShendoXT/memcardrex): `MemcardRex.Core/ps1card.cs`
|
||||
(`getGmeHeader`, `OpenMemoryCard`, `calculateXOR`, `loadStringData`,
|
||||
`FindSaveLinks`) and `MemcardRex.Core/Hardware/DexDrive.cs` (128-byte frame
|
||||
protocol and XOR framing on the wire).
|
||||
- The corresponding JAPS1ME implementations: `src/gme.rs`, `src/ps1/`.
|
||||
34
save-games/README.md
Normal file
34
save-games/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Community Save Games
|
||||
|
||||
This directory holds PlayStation 1 save containers shared for community use.
|
||||
Files remain in their original DexDrive `.gme` container format.
|
||||
|
||||
## Collection
|
||||
|
||||
| File | Format |
|
||||
| --- | --- |
|
||||
| `pro-evolution-soccer-2.22507.gme` | DexDrive (`.gme`) — Pro Evolution Soccer 2 (2 saves: `BESLES-03946PES-OPT`, `BESLES-03946PES-D2A`) |
|
||||
|
||||
## Using a save
|
||||
|
||||
Convert a save to RetroArch format with JAPS1ME:
|
||||
|
||||
```sh
|
||||
japs1me convert save-games/pro-evolution-soccer-2.22507.gme pes2.srm
|
||||
```
|
||||
|
||||
Back up any existing `.srm` first. `convert` never overwrites an existing
|
||||
output file.
|
||||
|
||||
## Adding saves
|
||||
|
||||
When contributing a save, keep its original `.gme` container and use a
|
||||
descriptive filename. Include the game, region or product code when known, a
|
||||
brief description, and where the save came from. Only contribute files you
|
||||
created or have permission to redistribute. Do not include personal
|
||||
information or credentials in save data.
|
||||
|
||||
The JAPS1ME project is GPL-3.0-or-later, but that license does **not**
|
||||
automatically apply to save files in this directory. No separate license or
|
||||
redistribution rights are implied for an individual save; retain any
|
||||
attribution, terms, and permissions that apply to its source.
|
||||
BIN
save-games/pro-evolution-soccer-2.22507.gme
Normal file
BIN
save-games/pro-evolution-soccer-2.22507.gme
Normal file
Binary file not shown.
144
src/commands/list.rs
Normal file
144
src/commands/list.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
//! Unicode table rendering for `japs1me list`.
|
||||
|
||||
use crate::ps1::card::SlotInfo;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
const HEADERS: [&str; 7] = [
|
||||
"Slot", "Save", "Title", "Region", "Blocks", "Size", "Status",
|
||||
];
|
||||
|
||||
/// Format a byte count with binary units.
|
||||
pub fn format_size(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
let mut value = bytes as f64 / 1024.0;
|
||||
let mut unit = 1;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
|
||||
fn pad_cell(content: &str, width: usize, right_align: bool) -> String {
|
||||
let content_width = UnicodeWidthStr::width(content);
|
||||
let padding = width.saturating_sub(content_width);
|
||||
if right_align {
|
||||
format!("{}{content}", " ".repeat(padding))
|
||||
} else {
|
||||
format!("{content}{}", " ".repeat(padding))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render saves as a Unicode table with a free-slot footer.
|
||||
pub fn format_save_table(saves: &[SlotInfo], free_slots: usize) -> String {
|
||||
let rows: Vec<[String; 7]> = saves
|
||||
.iter()
|
||||
.map(|save| {
|
||||
[
|
||||
(save.slot + 1).to_string(),
|
||||
save.save_name.clone(),
|
||||
save.game_title.clone(),
|
||||
save.region.clone(),
|
||||
save.blocks.to_string(),
|
||||
format_size(save.size_bytes as u64),
|
||||
if save.deleted {
|
||||
"deleted".to_owned()
|
||||
} else {
|
||||
"active".to_owned()
|
||||
},
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut widths = HEADERS.map(UnicodeWidthStr::width);
|
||||
for row in &rows {
|
||||
for (index, cell) in row.iter().enumerate() {
|
||||
widths[index] = widths[index].max(UnicodeWidthStr::width(cell.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
let border = |left: &str, middle: &str, right: &str| -> String {
|
||||
let mut line = String::from(left);
|
||||
for (index, width) in widths.iter().enumerate() {
|
||||
if index > 0 {
|
||||
line.push_str(middle);
|
||||
}
|
||||
line.push_str(&"─".repeat(width + 2));
|
||||
}
|
||||
line.push_str(right);
|
||||
line
|
||||
};
|
||||
let row_line = |cells: &[String; 7]| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (index, cell) in cells.iter().enumerate() {
|
||||
let right_align = matches!(index, 0 | 4 | 5);
|
||||
line.push(' ');
|
||||
line.push_str(&pad_cell(cell, widths[index], right_align));
|
||||
line.push_str(" │");
|
||||
}
|
||||
line
|
||||
};
|
||||
|
||||
let header_cells = HEADERS.map(String::from);
|
||||
let mut output = String::new();
|
||||
output.push_str(&border("┌", "┬", "┐"));
|
||||
output.push('\n');
|
||||
output.push_str(&row_line(&header_cells));
|
||||
output.push('\n');
|
||||
output.push_str(&border("├", "┼", "┤"));
|
||||
output.push('\n');
|
||||
for row in &rows {
|
||||
output.push_str(&row_line(row));
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&border("└", "┴", "┘"));
|
||||
output.push('\n');
|
||||
output.push_str(&format!("Free slots: {free_slots} of 15"));
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ps1::card::SlotInfo;
|
||||
|
||||
fn sample_save() -> SlotInfo {
|
||||
SlotInfo {
|
||||
slot: 0,
|
||||
save_name: "BESLES-03946PES-OPT".to_owned(),
|
||||
region: "Europe".to_owned(),
|
||||
game_title: "PES Option File".to_owned(),
|
||||
blocks: 2,
|
||||
size_bytes: 16384,
|
||||
deleted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_table_and_footer() {
|
||||
let table = format_save_table(&[sample_save()], 11);
|
||||
assert!(table.contains("┌"));
|
||||
assert!(table.contains("Slot"));
|
||||
assert!(table.contains("BESLES-03946PES-OPT"));
|
||||
assert!(table.contains("PES Option File"));
|
||||
assert!(table.contains("Free slots: 11 of 15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_empty_card() {
|
||||
let table = format_save_table(&[], 15);
|
||||
assert!(table.contains("Slot"));
|
||||
assert!(table.contains("Free slots: 15 of 15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_deleted_saves() {
|
||||
let mut save = sample_save();
|
||||
save.deleted = true;
|
||||
let table = format_save_table(&[save], 13);
|
||||
assert!(table.contains("deleted"));
|
||||
}
|
||||
}
|
||||
4
src/commands/mod.rs
Normal file
4
src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Unicode table rendering for `japs1me list`.
|
||||
|
||||
pub mod list;
|
||||
pub use list::{format_save_table, format_size};
|
||||
168
src/gme.rs
Normal file
168
src/gme.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! DexDrive `.gme` memory-card image parsing.
|
||||
//!
|
||||
//! A `.gme` file is a 3904-byte header followed by a 131072-byte raw card
|
||||
//! image, for a total of 134976 bytes. The canonical header starts with the
|
||||
//! `123-456-STD` magic and carries per-slot status bytes and 256-byte
|
||||
//! comments. Some files in the wild (including the PES 2 fixture in
|
||||
//! `save-games/`) have a zeroed or otherwise non-canonical header; those are
|
||||
//! accepted as long as the embedded raw image starts with `MC`, matching
|
||||
//! MemcardRex's corrupted-header fallback.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Length of the DexDrive header prepended to the raw card image.
|
||||
pub const GME_HEADER_LEN: usize = 3904;
|
||||
/// Length of the raw card image (also the `.srm` length).
|
||||
pub const RAW_CARD_LEN: usize = 131072;
|
||||
/// Total length of a `.gme` file.
|
||||
pub const GME_FILE_LEN: usize = GME_HEADER_LEN + RAW_CARD_LEN;
|
||||
/// Canonical DexDrive header magic (`123-456-STD`).
|
||||
pub const GME_MAGIC: &[u8; 11] = b"123-456-STD";
|
||||
/// Offset of the per-slot comments within the header.
|
||||
const COMMENT_OFFSET: usize = 64;
|
||||
/// Length of each per-slot comment in the header.
|
||||
const COMMENT_LEN: usize = 256;
|
||||
|
||||
/// A parsed DexDrive image.
|
||||
pub struct GmeCard {
|
||||
/// True when the header carries the canonical `123-456-STD` magic.
|
||||
pub header_present: bool,
|
||||
/// Per-slot comments decoded from the header (empty for `.srm`/zeroed headers).
|
||||
pub comments: [String; 15],
|
||||
/// The raw 131072-byte card image.
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse `.gme` bytes, returning the card plus non-fatal warnings.
|
||||
///
|
||||
/// Warnings cover the missing-header fallback; hard errors cover wrong file
|
||||
/// length and a missing `MC` signature at the raw offset.
|
||||
pub fn parse_gme(bytes: &[u8]) -> Result<(GmeCard, Vec<String>)> {
|
||||
let mut warnings = Vec::new();
|
||||
if bytes.len() != GME_FILE_LEN {
|
||||
bail!(
|
||||
"not a DexDrive .gme file: expected {} bytes, found {}",
|
||||
GME_FILE_LEN,
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
let header = &bytes[..GME_HEADER_LEN];
|
||||
let raw = &bytes[GME_HEADER_LEN..];
|
||||
if raw[0] != b'M' || raw[1] != b'C' {
|
||||
bail!("not a DexDrive .gme file: missing MC signature at raw offset");
|
||||
}
|
||||
let header_present = header[..GME_MAGIC.len()] == GME_MAGIC[..];
|
||||
if !header_present {
|
||||
warnings.push(
|
||||
"missing DexDrive header magic (headerless or zeroed header); treating bytes 3904.. as the raw card".to_owned(),
|
||||
);
|
||||
}
|
||||
let mut comments: [String; 15] = Default::default();
|
||||
for (slot, comment) in comments.iter_mut().enumerate() {
|
||||
let start = COMMENT_OFFSET + slot * COMMENT_LEN;
|
||||
*comment = decode_comment(&header[start..start + COMMENT_LEN]);
|
||||
}
|
||||
Ok((
|
||||
GmeCard {
|
||||
header_present,
|
||||
comments,
|
||||
raw: raw.to_vec(),
|
||||
},
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode one 256-byte header comment using Windows-1252 (MemcardRex uses the
|
||||
/// system codepage, which is Windows-1252 on Western systems).
|
||||
fn decode_comment(bytes: &[u8]) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
let (text, _, _) = encoding_rs::WINDOWS_1252.decode(&bytes[..end]);
|
||||
text.trim_end_matches(['\0', ' ', '\t', '\r', '\n'])
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_raw() -> Vec<u8> {
|
||||
let mut raw = vec![0u8; RAW_CARD_LEN];
|
||||
raw[0] = b'M';
|
||||
raw[1] = b'C';
|
||||
// One formatted directory frame with a valid XOR checksum.
|
||||
let off = 128;
|
||||
raw[off] = 0xA0;
|
||||
raw[off + 8] = 0xFF;
|
||||
raw[off + 9] = 0xFF;
|
||||
let mut xor = 0u8;
|
||||
for b in &raw[off..off + 127] {
|
||||
xor ^= b;
|
||||
}
|
||||
raw[off + 127] = xor;
|
||||
raw
|
||||
}
|
||||
|
||||
fn canonical_header() -> Vec<u8> {
|
||||
let mut header = vec![0u8; GME_HEADER_LEN];
|
||||
header[..GME_MAGIC.len()].copy_from_slice(GME_MAGIC);
|
||||
header
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_header_without_warnings() {
|
||||
let mut file = canonical_header();
|
||||
file.extend_from_slice(&sample_raw());
|
||||
let (card, warnings) = parse_gme(&file).expect("canonical file parses");
|
||||
assert!(card.header_present);
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(card.raw.len(), RAW_CARD_LEN);
|
||||
assert_eq!(&card.raw[0..2], b"MC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_headerless_file_with_warning() {
|
||||
let mut file = vec![0u8; GME_HEADER_LEN];
|
||||
file.extend_from_slice(&sample_raw());
|
||||
let (card, warnings) = parse_gme(&file).expect("headerless file parses");
|
||||
assert!(!card.header_present);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(card.raw.len(), RAW_CARD_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_length() {
|
||||
assert!(parse_gme(&[0u8; RAW_CARD_LEN]).is_err());
|
||||
assert!(parse_gme(&[0u8; 100]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_mc_signature() {
|
||||
let mut file = canonical_header();
|
||||
file.extend_from_slice(&vec![0u8; RAW_CARD_LEN]);
|
||||
assert!(parse_gme(&file).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_header_comments() {
|
||||
let mut header = canonical_header();
|
||||
header[COMMENT_OFFSET..COMMENT_OFFSET + 5].copy_from_slice(b"Hello");
|
||||
let mut file = header;
|
||||
file.extend_from_slice(&sample_raw());
|
||||
let (card, _) = parse_gme(&file).expect("parses");
|
||||
assert_eq!(card.comments[0], "Hello");
|
||||
assert_eq!(card.comments[1], "");
|
||||
}
|
||||
|
||||
const PES2_GME: &[u8] = include_bytes!("../save-games/pro-evolution-soccer-2.22507.gme");
|
||||
|
||||
#[test]
|
||||
fn pes2_fixture_strips_to_raw() {
|
||||
assert_eq!(PES2_GME.len(), GME_FILE_LEN);
|
||||
let (card, warnings) = parse_gme(PES2_GME).expect("fixture parses");
|
||||
assert!(!card.header_present);
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(&card.raw[0..2], b"MC");
|
||||
assert_eq!(card.raw.len(), RAW_CARD_LEN);
|
||||
assert_eq!(card.raw, &PES2_GME[GME_HEADER_LEN..]);
|
||||
}
|
||||
}
|
||||
14
src/lib.rs
Normal file
14
src/lib.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! JAPS1ME - PS1 RetroArch Save Converter
|
||||
//!
|
||||
//! A library for reading DexDrive (`.gme`) and raw (`.srm`/`.mcr`) PlayStation 1
|
||||
//! memory-card images, with support for converting `.gme` files to RetroArch
|
||||
//! `.srm` format.
|
||||
|
||||
pub mod commands;
|
||||
pub mod gme;
|
||||
pub mod ps1;
|
||||
|
||||
pub use commands::list::{format_save_table, format_size};
|
||||
pub use gme::{parse_gme, GmeCard, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
|
||||
pub use ps1::card::{parse_raw, CardSummary, SlotInfo, SlotType};
|
||||
pub use ps1::title::decode_title;
|
||||
103
src/main.rs
Normal file
103
src/main.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use japs1me::gme::{parse_gme, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
|
||||
use japs1me::{format_save_table, parse_raw};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "japs1me",
|
||||
version,
|
||||
about = "JAPS1ME - PS1 RetroArch Save Converter (DexDrive .gme to RetroArch .srm)"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// List saves on a .gme or raw (.srm/.mcr) card image.
|
||||
List { card: String },
|
||||
/// Convert a DexDrive .gme file to a RetroArch .srm file.
|
||||
Convert { input: String, output: 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}");
|
||||
}
|
||||
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)
|
||||
.with_context(|| format!("reading {}", Path::new(&input).display()))?;
|
||||
if bytes.len() != GME_FILE_LEN {
|
||||
bail!(
|
||||
"not a DexDrive .gme file: expected {GME_FILE_LEN} bytes, found {}",
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
let (card, warnings) = parse_gme(&bytes)?;
|
||||
for warning in warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
let summary = parse_raw(&card.raw)?;
|
||||
for warning in &summary.warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
}
|
||||
write_output_atomically(&output, &card.raw)?;
|
||||
eprintln!(
|
||||
"Converted {} ({} save(s)) to {}",
|
||||
Path::new(&input).display(),
|
||||
summary.saves.len(),
|
||||
Path::new(&output).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
&& bytes[GME_HEADER_LEN] == b'M'
|
||||
&& bytes[GME_HEADER_LEN + 1] == b'C'
|
||||
{
|
||||
let (card, warnings) = parse_gme(bytes)?;
|
||||
return Ok((card.raw, warnings));
|
||||
}
|
||||
if bytes.len() == RAW_CARD_LEN && bytes[0] == b'M' && bytes[1] == b'C' {
|
||||
return Ok((bytes.to_vec(), Vec::new()));
|
||||
}
|
||||
bail!(
|
||||
"unsupported card format for '{name}': expected a .gme file ({GME_FILE_LEN} bytes) or a raw .srm image ({RAW_CARD_LEN} bytes starting with MC)"
|
||||
)
|
||||
}
|
||||
|
||||
fn write_output_atomically(output: &str, data: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let output = PathBuf::from(output);
|
||||
if output.symlink_metadata().is_ok() {
|
||||
bail!("output already exists: {}", output.display());
|
||||
}
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty());
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent.unwrap_or_else(|| Path::new(".")))
|
||||
.context("creating temporary output file")?;
|
||||
temp.write_all(data)
|
||||
.context("writing temporary output file")?;
|
||||
temp.persist(&output)
|
||||
.with_context(|| format!("writing {}", output.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
385
src/ps1/card.rs
Normal file
385
src/ps1/card.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
//! Raw PS1 memory-card parsing (`.srm`/`.mcr` and stripped `.gme` payloads).
|
||||
//!
|
||||
//! Layout (all offsets into the 131072-byte raw image):
|
||||
//! - Bytes `0..128`: card header frame (`MC` magic).
|
||||
//! - Bytes `128..2048`: 15 directory frames of 128 bytes.
|
||||
//! - Bytes `8192..`: 15 save data blocks of 8192 bytes.
|
||||
//!
|
||||
//! Directory frame fields used here: type byte at 0, 3-byte little-endian
|
||||
//! payload size at 4..7, next-slot pointer at 8, region at 10..12, product
|
||||
//! code at 12..22, identifier at 22..30, and an XOR checksum at 127 covering
|
||||
//! bytes `0..127` (see MemcardRex `calculateXOR`).
|
||||
|
||||
use crate::gme::RAW_CARD_LEN;
|
||||
use crate::ps1::title::decode_title;
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
/// Number of save slots on a PS1 card.
|
||||
pub const SLOT_COUNT: usize = 15;
|
||||
/// Length of one directory frame.
|
||||
pub const DIR_FRAME_LEN: usize = 128;
|
||||
/// Length of one save data block.
|
||||
pub const SAVE_BLOCK_LEN: usize = 8192;
|
||||
/// Offset of the directory frames in the raw image.
|
||||
const DIR_FRAMES_OFFSET: usize = 128;
|
||||
/// Offset of the save data blocks in the raw image.
|
||||
const SAVE_DATA_OFFSET: usize = 8192;
|
||||
|
||||
/// Classification of a directory-frame slot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlotType {
|
||||
Formatted,
|
||||
Initial,
|
||||
MiddleLink,
|
||||
EndLink,
|
||||
DeletedInitial,
|
||||
DeletedMiddleLink,
|
||||
DeletedEndLink,
|
||||
Corrupted,
|
||||
}
|
||||
|
||||
impl SlotType {
|
||||
fn from_byte(byte: u8) -> Self {
|
||||
match byte {
|
||||
0xA0 => Self::Formatted,
|
||||
0x51 => Self::Initial,
|
||||
0x52 => Self::MiddleLink,
|
||||
0x53 => Self::EndLink,
|
||||
0xA1 => Self::DeletedInitial,
|
||||
0xA2 => Self::DeletedMiddleLink,
|
||||
0xA3 => Self::DeletedEndLink,
|
||||
_ => Self::Corrupted,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_initial(self) -> bool {
|
||||
matches!(self, Self::Initial | Self::DeletedInitial)
|
||||
}
|
||||
|
||||
fn is_link(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::MiddleLink | Self::EndLink | Self::DeletedMiddleLink | Self::DeletedEndLink
|
||||
)
|
||||
}
|
||||
|
||||
fn is_deleted(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::DeletedInitial | Self::DeletedMiddleLink | Self::DeletedEndLink
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct DirFrame {
|
||||
slot_type: SlotType,
|
||||
next: u8,
|
||||
region_raw: String,
|
||||
product_code: String,
|
||||
identifier: String,
|
||||
}
|
||||
|
||||
/// One save (an initial slot plus its linked blocks).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SlotInfo {
|
||||
/// Zero-based initial slot.
|
||||
pub slot: usize,
|
||||
/// Save name (`region + product code + identifier`, e.g. `BESLES-03946PES-OPT`).
|
||||
pub save_name: String,
|
||||
/// Human-readable region (`America`/`Europe`/`Japan`, else the raw code).
|
||||
pub region: String,
|
||||
/// Game title decoded from the save data (falls back to the save name).
|
||||
pub game_title: String,
|
||||
/// Number of slots (blocks) the save occupies.
|
||||
pub blocks: usize,
|
||||
/// Payload size in bytes (`blocks * 8192`).
|
||||
pub size_bytes: usize,
|
||||
/// True when the save is marked deleted.
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// Parsed card: saves in slot order plus free-slot count and warnings.
|
||||
pub struct CardSummary {
|
||||
pub saves: Vec<SlotInfo>,
|
||||
pub free_slots: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parse a 131072-byte raw card image.
|
||||
pub fn parse_raw(raw: &[u8]) -> Result<CardSummary> {
|
||||
if raw.len() != RAW_CARD_LEN {
|
||||
bail!(
|
||||
"not a raw PS1 card image: expected {} bytes, found {}",
|
||||
RAW_CARD_LEN,
|
||||
raw.len()
|
||||
);
|
||||
}
|
||||
if raw[0] != b'M' || raw[1] != b'C' {
|
||||
bail!("not a raw PS1 card image: missing MC signature");
|
||||
}
|
||||
|
||||
let mut frames = Vec::with_capacity(SLOT_COUNT);
|
||||
let mut warnings = Vec::new();
|
||||
for slot in 0..SLOT_COUNT {
|
||||
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
|
||||
let frame = &raw[start..start + DIR_FRAME_LEN];
|
||||
let mut xor = 0u8;
|
||||
for byte in &frame[..DIR_FRAME_LEN - 1] {
|
||||
xor ^= byte;
|
||||
}
|
||||
if xor != frame[DIR_FRAME_LEN - 1] {
|
||||
warnings.push(format!("slot {}: directory checksum mismatch", slot + 1));
|
||||
}
|
||||
frames.push(DirFrame {
|
||||
slot_type: SlotType::from_byte(frame[0]),
|
||||
next: frame[8],
|
||||
region_raw: decode_ascii(&frame[10..12]),
|
||||
product_code: decode_ascii(&frame[12..22]),
|
||||
identifier: decode_ascii(&frame[22..30]),
|
||||
});
|
||||
}
|
||||
|
||||
// Follow link chains from each initial slot.
|
||||
let mut reachable = [false; SLOT_COUNT];
|
||||
let mut saves = Vec::new();
|
||||
for slot in 0..SLOT_COUNT {
|
||||
if !frames[slot].slot_type.is_initial() {
|
||||
continue;
|
||||
}
|
||||
let chain = find_links(&frames, slot);
|
||||
for &linked in &chain {
|
||||
reachable[linked] = true;
|
||||
}
|
||||
let frame = &frames[slot];
|
||||
let save_name = format!(
|
||||
"{}{}{}",
|
||||
frame.region_raw, frame.product_code, frame.identifier
|
||||
);
|
||||
let data_start = SAVE_DATA_OFFSET + slot * SAVE_BLOCK_LEN;
|
||||
let title_field = &raw[data_start + 4..data_start + 4 + crate::ps1::title::TITLE_LEN];
|
||||
let game_title = decode_title(title_field).unwrap_or_else(|| save_name.clone());
|
||||
let blocks = chain.len();
|
||||
saves.push(SlotInfo {
|
||||
slot,
|
||||
save_name,
|
||||
region: display_region(&frame.region_raw),
|
||||
game_title,
|
||||
blocks,
|
||||
size_bytes: blocks * SAVE_BLOCK_LEN,
|
||||
deleted: frame.slot_type.is_deleted(),
|
||||
});
|
||||
}
|
||||
|
||||
// Slots never reached from an initial slot but typed as links hold stale
|
||||
// data (broken links); MemcardRex reclassifies those as free.
|
||||
let mut free_slots = 0;
|
||||
for slot in 0..SLOT_COUNT {
|
||||
match frames[slot].slot_type {
|
||||
SlotType::Formatted => free_slots += 1,
|
||||
slot_type if slot_type.is_link() && !reachable[slot] => {
|
||||
free_slots += 1;
|
||||
warnings.push(format!("slot {}: orphaned link treated as free", slot + 1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CardSummary {
|
||||
saves,
|
||||
free_slots,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Follow a save's link chain, with bounds mirroring MemcardRex `FindSaveLinks`.
|
||||
fn find_links(frames: &[DirFrame], start: usize) -> Vec<usize> {
|
||||
let mut chain = vec![start];
|
||||
let mut current = start;
|
||||
for _ in 0..SLOT_COUNT {
|
||||
let next = frames[current].next;
|
||||
if next == 0xFF {
|
||||
break;
|
||||
}
|
||||
let next = next as usize;
|
||||
if next >= SLOT_COUNT {
|
||||
break;
|
||||
}
|
||||
if !frames[next].slot_type.is_link() {
|
||||
break;
|
||||
}
|
||||
let end = matches!(
|
||||
frames[next].slot_type,
|
||||
SlotType::EndLink | SlotType::DeletedEndLink
|
||||
);
|
||||
chain.push(next);
|
||||
current = next;
|
||||
if end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
fn decode_ascii(bytes: &[u8]) -> String {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
bytes[..end]
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
if b.is_ascii_graphic() || b == b' ' {
|
||||
b as char
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn display_region(raw: &str) -> String {
|
||||
match raw {
|
||||
"BA" => "America".to_owned(),
|
||||
"BE" => "Europe".to_owned(),
|
||||
"BI" => "Japan".to_owned(),
|
||||
other => other.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn blank_raw() -> Vec<u8> {
|
||||
let mut raw = vec![0u8; RAW_CARD_LEN];
|
||||
raw[0] = b'M';
|
||||
raw[1] = b'C';
|
||||
for slot in 0..SLOT_COUNT {
|
||||
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
|
||||
raw[start] = 0xA0;
|
||||
raw[start + 8] = 0xFF;
|
||||
raw[start + 9] = 0xFF;
|
||||
fix_xor(&mut raw, start);
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
fn fix_xor(raw: &mut [u8], start: usize) {
|
||||
let mut xor = 0u8;
|
||||
for byte in &raw[start..start + DIR_FRAME_LEN - 1] {
|
||||
xor ^= byte;
|
||||
}
|
||||
raw[start + DIR_FRAME_LEN - 1] = xor;
|
||||
}
|
||||
|
||||
fn write_frame(raw: &mut [u8], slot: usize, frame: &[u8; DIR_FRAME_LEN]) {
|
||||
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
|
||||
raw[start..start + DIR_FRAME_LEN].copy_from_slice(frame);
|
||||
}
|
||||
|
||||
fn initial_frame(next: u8, region: &[u8; 2], prod: &[u8], ident: &[u8]) -> [u8; DIR_FRAME_LEN] {
|
||||
let mut frame = [0u8; DIR_FRAME_LEN];
|
||||
frame[0] = 0x51;
|
||||
frame[4] = 0x00;
|
||||
frame[5] = 0x20;
|
||||
frame[8] = next;
|
||||
frame[9] = if next == 0xFF { 0xFF } else { 0x00 };
|
||||
frame[10..12].copy_from_slice(region);
|
||||
frame[12..12 + prod.len().min(10)].copy_from_slice(&prod[..prod.len().min(10)]);
|
||||
frame[22..22 + ident.len().min(8)].copy_from_slice(&ident[..ident.len().min(8)]);
|
||||
let mut xor = 0u8;
|
||||
for byte in &frame[..DIR_FRAME_LEN - 1] {
|
||||
xor ^= byte;
|
||||
}
|
||||
frame[DIR_FRAME_LEN - 1] = xor;
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_empty_card() {
|
||||
let summary = parse_raw(&blank_raw()).expect("parses");
|
||||
assert!(summary.saves.is_empty());
|
||||
assert_eq!(summary.free_slots, SLOT_COUNT);
|
||||
assert!(summary.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_single_block_save() {
|
||||
let mut raw = blank_raw();
|
||||
write_frame(
|
||||
&mut raw,
|
||||
0,
|
||||
&initial_frame(0xFF, b"BE", b"SLES-03946", b"PES-OPT"),
|
||||
);
|
||||
let data = SAVE_DATA_OFFSET;
|
||||
raw[data + 4..data + 11].copy_from_slice(b"PES-OPT");
|
||||
let summary = parse_raw(&raw).expect("parses");
|
||||
assert_eq!(summary.saves.len(), 1);
|
||||
let save = &summary.saves[0];
|
||||
assert_eq!(save.slot, 0);
|
||||
assert_eq!(save.save_name, "BESLES-03946PES-OPT");
|
||||
assert_eq!(save.region, "Europe");
|
||||
assert_eq!(save.game_title, "PES-OPT");
|
||||
assert_eq!(save.blocks, 1);
|
||||
assert_eq!(save.size_bytes, SAVE_BLOCK_LEN);
|
||||
assert!(!save.deleted);
|
||||
assert_eq!(summary.free_slots, SLOT_COUNT - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_multi_block_links() {
|
||||
let mut raw = blank_raw();
|
||||
write_frame(
|
||||
&mut raw,
|
||||
0,
|
||||
&initial_frame(1, b"BA", b"SLUS-12345", b"SAVE1"),
|
||||
);
|
||||
let mut end = [0u8; DIR_FRAME_LEN];
|
||||
end[0] = 0x53;
|
||||
end[8] = 0xFF;
|
||||
end[9] = 0xFF;
|
||||
let mut xor = 0u8;
|
||||
for byte in &end[..DIR_FRAME_LEN - 1] {
|
||||
xor ^= byte;
|
||||
}
|
||||
end[DIR_FRAME_LEN - 1] = xor;
|
||||
write_frame(&mut raw, 1, &end);
|
||||
let summary = parse_raw(&raw).expect("parses");
|
||||
assert_eq!(summary.saves.len(), 1);
|
||||
assert_eq!(summary.saves[0].blocks, 2);
|
||||
assert_eq!(summary.free_slots, SLOT_COUNT - 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_bad_xor_without_failing() {
|
||||
let mut raw = blank_raw();
|
||||
let start = DIR_FRAMES_OFFSET;
|
||||
raw[start + 127] ^= 0xFF;
|
||||
let summary = parse_raw(&raw).expect("still parses");
|
||||
assert_eq!(summary.warnings.len(), 1);
|
||||
assert!(summary.warnings[0].contains("checksum mismatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_input() {
|
||||
assert!(parse_raw(&[0u8; 100]).is_err());
|
||||
assert!(parse_raw(&[0u8; RAW_CARD_LEN]).is_err());
|
||||
}
|
||||
|
||||
const PES2_GME: &[u8] = include_bytes!("../../save-games/pro-evolution-soccer-2.22507.gme");
|
||||
|
||||
#[test]
|
||||
fn pes2_fixture_has_two_two_block_saves() {
|
||||
let raw = &PES2_GME[crate::gme::GME_HEADER_LEN..];
|
||||
let summary = parse_raw(raw).expect("fixture parses");
|
||||
assert_eq!(summary.saves.len(), 2);
|
||||
assert_eq!(summary.saves[0].save_name, "BESLES-03946PES-OPT");
|
||||
assert_eq!(summary.saves[0].blocks, 2);
|
||||
assert_eq!(summary.saves[0].region, "Europe");
|
||||
assert!(!summary.saves[0].game_title.is_empty());
|
||||
assert_ne!(summary.saves[0].game_title, summary.saves[0].save_name);
|
||||
assert_eq!(summary.saves[1].save_name, "BESLES-03946PES-D2A");
|
||||
assert_eq!(summary.saves[1].blocks, 2);
|
||||
assert_eq!(summary.free_slots, 11);
|
||||
assert!(summary.warnings.is_empty());
|
||||
}
|
||||
}
|
||||
4
src/ps1/mod.rs
Normal file
4
src/ps1/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! PlayStation 1 memory-card model.
|
||||
|
||||
pub mod card;
|
||||
pub mod title;
|
||||
65
src/ps1/title.rs
Normal file
65
src/ps1/title.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Shift-JIS save-title decoding for PS1 saves.
|
||||
//!
|
||||
//! Each save's data block starts with a 64-byte Shift-JIS title at offset 4.
|
||||
//! The field is NUL-terminated on an even byte boundary (matching MemcardRex's
|
||||
//! `loadStringData` behavior).
|
||||
|
||||
/// Length of the title field in a save data block.
|
||||
pub const TITLE_LEN: usize = 64;
|
||||
|
||||
/// Decode a save title from the 64-byte field at data offset 4.
|
||||
///
|
||||
/// Returns `None` when the field carries no usable text.
|
||||
pub fn decode_title(field: &[u8]) -> Option<String> {
|
||||
let mut len = field.len().min(TITLE_LEN);
|
||||
for (index, &byte) in field.iter().enumerate().take(len) {
|
||||
if index % 2 == 0 && byte == 0 {
|
||||
len = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let field = &field[..len];
|
||||
let (text, _, _) = encoding_rs::SHIFT_JIS.decode(field);
|
||||
let title = text
|
||||
.trim_matches(|c: char| c == '\0' || c.is_whitespace())
|
||||
.to_owned();
|
||||
if title.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(title)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_ascii_title() {
|
||||
let mut field = [0u8; TITLE_LEN];
|
||||
field[..7].copy_from_slice(b"PES-OPT");
|
||||
assert_eq!(decode_title(&field).as_deref(), Some("PES-OPT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_shift_jis_title() {
|
||||
// "ゲーム" in Shift-JIS.
|
||||
let mut field = [0u8; TITLE_LEN];
|
||||
field[..6].copy_from_slice(&[0x83, 0x51, 0x81, 0x5b, 0x83, 0x80]);
|
||||
assert_eq!(decode_title(&field).as_deref(), Some("ゲーム"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_empty_title() {
|
||||
assert_eq!(decode_title(&[0u8; TITLE_LEN]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_at_even_nul() {
|
||||
let mut field = [0u8; TITLE_LEN];
|
||||
field[..3].copy_from_slice(b"ABC");
|
||||
// NUL at even index 4 terminates; trailing bytes are ignored.
|
||||
field[6..9].copy_from_slice(b"XYZ");
|
||||
assert_eq!(decode_title(&field).as_deref(), Some("ABC"));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue