commit 23cd7065fee8a48b65afaa3f5fe5c5c15cac478f Author: markmental Date: Mon Oct 12 17:00:28 2026 -0400 Initial Commit 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 0000000..b860983 Binary files /dev/null and b/save-games/pro-evolution-soccer-2.22507.gme differ 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")); + } +}