From 23cd7065fee8a48b65afaa3f5fe5c5c15cac478f Mon Sep 17 00:00:00 2001 From: markmental Date: Mon, 12 Oct 2026 17:00:28 -0400 Subject: [PATCH] Initial Commit --- .gitignore | 4 + AGENTS.md | 66 ++++ Cargo.lock | 335 +++++++++++++++++ Cargo.toml | 21 ++ README.md | 83 +++++ docs/FORMATS.md | 76 ++++ save-games/README.md | 34 ++ save-games/pro-evolution-soccer-2.22507.gme | Bin 0 -> 134976 bytes src/commands/list.rs | 144 ++++++++ src/commands/mod.rs | 4 + src/gme.rs | 168 +++++++++ src/lib.rs | 14 + src/main.rs | 103 ++++++ src/ps1/card.rs | 385 ++++++++++++++++++++ src/ps1/mod.rs | 4 + src/ps1/title.rs | 65 ++++ 16 files changed, 1506 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 docs/FORMATS.md create mode 100644 save-games/README.md create mode 100644 save-games/pro-evolution-soccer-2.22507.gme create mode 100644 src/commands/list.rs create mode 100644 src/commands/mod.rs create mode 100644 src/gme.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/ps1/card.rs create mode 100644 src/ps1/mod.rs create mode 100644 src/ps1/title.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..471998b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +JAPS2ME/ +memcardrex/ +Pro*.srm diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..008f484 --- /dev/null +++ b/AGENTS.md @@ -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 ` — list saves as a Unicode table with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots. +- `convert ` — validate a DexDrive `.gme` file and write its raw payload as `.srm`. +- `--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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d2b975e --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f66b5bd --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca80f4c --- /dev/null +++ b/README.md @@ -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 ` | List saves and free slots. | +| `japs1me convert ` | 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. diff --git a/docs/FORMATS.md b/docs/FORMATS.md new file mode 100644 index 0000000..e71e95c --- /dev/null +++ b/docs/FORMATS.md @@ -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/`. diff --git a/save-games/README.md b/save-games/README.md new file mode 100644 index 0000000..2060208 --- /dev/null +++ b/save-games/README.md @@ -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. diff --git a/save-games/pro-evolution-soccer-2.22507.gme b/save-games/pro-evolution-soccer-2.22507.gme new file mode 100644 index 0000000000000000000000000000000000000000..b860983453e293f3fe6e4b7c0d41ba67aca4c018 GIT binary patch literal 134976 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 literal 0 HcmV?d00001 diff --git a/src/commands/list.rs b/src/commands/list.rs new file mode 100644 index 0000000..44be6c0 --- /dev/null +++ b/src/commands/list.rs @@ -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")); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..ccca4fd --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,4 @@ +//! Unicode table rendering for `japs1me list`. + +pub mod list; +pub use list::{format_save_table, format_size}; diff --git a/src/gme.rs b/src/gme.rs new file mode 100644 index 0000000..42d141f --- /dev/null +++ b/src/gme.rs @@ -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, +} + +/// 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)> { + 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 { + 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 { + 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..]); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3e4f166 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e1478cd --- /dev/null +++ b/src/main.rs @@ -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, Vec)> { + 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(()) +} diff --git a/src/ps1/card.rs b/src/ps1/card.rs new file mode 100644 index 0000000..774fae4 --- /dev/null +++ b/src/ps1/card.rs @@ -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, + pub free_slots: usize, + pub warnings: Vec, +} + +/// Parse a 131072-byte raw card image. +pub fn parse_raw(raw: &[u8]) -> Result { + 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 { + 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::() + .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 { + 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()); + } +} diff --git a/src/ps1/mod.rs b/src/ps1/mod.rs new file mode 100644 index 0000000..62f749d --- /dev/null +++ b/src/ps1/mod.rs @@ -0,0 +1,4 @@ +//! PlayStation 1 memory-card model. + +pub mod card; +pub mod title; diff --git a/src/ps1/title.rs b/src/ps1/title.rs new file mode 100644 index 0000000..5316fb1 --- /dev/null +++ b/src/ps1/title.rs @@ -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 { + 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")); + } +}