Initial Commit

This commit is contained in:
markmental 2026-10-12 17:00:28 -04:00
commit 23cd7065fe
16 changed files with 1506 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
target/
JAPS2ME/
memcardrex/
Pro*.srm

66
AGENTS.md Normal file
View file

@ -0,0 +1,66 @@
# Agent and Contributor Guide
## Project overview
JAPS1ME (PS1 RetroArch Save Converter) is a Rust CLI and library for listing
PlayStation 1 memory-card saves and converting DexDrive `.gme` files to
RetroArch `.srm` format. Current CLI commands:
- `list <CARD.gme|CARD.srm>` — list saves as a Unicode table with slot numbers, save names, titles, regions, block counts, payload sizes, and free slots.
- `convert <INPUT.gme> <OUTPUT.srm>` — validate a DexDrive `.gme` file and write its raw payload as `.srm`.
- `--version` / `-V` — print the package version and exit.
The current package version is `0.1.0`; keep the root package versions in `Cargo.toml` and `Cargo.lock` in sync.
The project is licensed under GPL-3.0-or-later. `memcardrex` is a technical
reference for PS1 memory-card and DexDrive behavior; see its attribution in
`README.md`. The `JAPS2ME/` and `memcardrex/` directories are reference
material, not part of the build.
## Repository map
- `src/main.rs` — Clap CLI, command dispatch, `.gme`/raw input detection, and atomic output writes.
- `src/commands/list.rs` — Unicode table rendering and byte-size formatting for `list`.
- `src/gme.rs` — DexDrive `.gme` header parsing, headerless fallback, and comment decoding.
- `src/ps1/card.rs` — raw card parsing, slot/link chains, region names, XOR warnings, and save summaries.
- `src/ps1/title.rs` — Shift-JIS save-title decoding.
- `src/lib.rs` — crate modules and public exports.
- `save-games/README.md` — inventory and contribution guidance for community-shared `.gme` saves in `save-games/`.
- `save-games/` — curated save containers, including the `pro-evolution-soccer-2.22507.gme` fixture used by `include_bytes!` tests; preserve these user-provided files and do not use them as writable integration-test targets.
## Build and checks
Run these from the repository root:
```sh
cargo fmt --check
cargo test
cargo clippy --all-targets -- -D warnings
cargo build --release
```
After changing code, rebuild the executable being invoked. For example, `cargo build` refreshes `target/debug/japs1me`; `cargo build --release` refreshes `target/release/japs1me`. A stale binary can continue exhibiting behavior fixed in the source.
## Memory-card safety
- Never use the user's only save as a test target. Make a temporary copy under `/tmp/opencode/` for integration tests.
- The convert command stages output in a temporary file, refuses to overwrite existing outputs, and only persists after validation.
- Recommend an independent backup before converting over anything important.
## Card invariants
- A raw card image is exactly 131072 bytes starting with `MC`. A `.gme` file is a 3904-byte header plus the raw image (134976 bytes total).
- Directory frames are 128 bytes at raw offset `128 + slot * 128`; save data blocks are 8192 bytes at raw offset `8192 + slot * 8192`.
- Directory byte 127 is the XOR of bytes `0..127`. JAPS1ME verifies checksums read-only and reports mismatches as warnings; it never recalculates them.
- A save is an initial slot (`0x51`/`0xA1`) plus its link chain via the next-pointer at frame byte 8 (`0xFF` terminates). Links unreachable from any initial slot are orphaned and counted as free, matching MemcardRex.
- Save names are `region + product code + identifier` (frame bytes 10..30, NUL-trimmed). Regions `BA`/`BE`/`BI` display as America/Europe/Japan.
- Save titles are 64-byte Shift-JIS fields at data offset 4, cut at the first even-index NUL; `list` falls back to the save name when no usable title exists.
- `list` summaries count saves in slot order; sizes are `blocks * 8192` payload bytes. Free slots are formatted frames plus orphaned links.
## Change guidelines
- Keep parsing separate from output writing; both input kinds should produce validated raw bytes before any write.
- Prefer focused tests for parser boundaries, link invariants, and refused overwrites.
- Do not weaken length, `MC` signature, or checksum checks to accept malformed input without a documented compatibility reason and a regression test. The headerless-`.gme` fallback is the documented exception.
- Save files in `save-games/` are separate assets, not automatically covered by the project's GPL license. Do not claim redistribution rights or assign a license without source/permission information; follow the contribution guidance in that directory's README.
- Update `README.md` when user-facing commands, supported formats, build steps, or safety behavior change.

335
Cargo.lock generated Normal file
View file

@ -0,0 +1,335 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "japs1me"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"encoding_rs",
"tempfile",
"unicode-width",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

21
Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "japs1me"
version = "0.1.0"
edition = "2021"
description = "JAPS1ME - PS1 RetroArch Save Converter (DexDrive .gme to RetroArch .srm)"
license = "GPL-3.0-or-later"
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
encoding_rs = "0.8"
unicode-width = "0.2"
tempfile = "3"
[[bin]]
name = "japs1me"
path = "src/main.rs"
[lib]
name = "japs1me"
path = "src/lib.rs"

83
README.md Normal file
View file

@ -0,0 +1,83 @@
# JAPS1ME
**JAPS1ME - PS1 RetroArch Save Converter** is a command-line tool for converting
DexDrive PlayStation 1 memory-card images (`.gme`) to RetroArch memory-card
format (`.srm`). It also lists the saves on `.gme` and raw (`.srm`/`.mcr`)
card images.
## Features
- Convert DexDrive `.gme` files (including headerless/zeroed-header variants) to RetroArch `.srm` with validated stripping.
- Browse saves with slot numbers, save names, Shift-JIS game titles, regions, block counts, payload sizes, and free slots.
- Read-only `list`; `convert` validates before writing and never overwrites an existing output file.
## Build
You need a Rust toolchain with Cargo installed.
```sh
cargo build --release
```
The executable is created at `target/release/japs1me`.
## Usage
| Command | Purpose |
| --- | --- |
| `japs1me --version` | Print the version (`-V` is also supported). |
| `japs1me list <CARD.gme\|CARD.srm>` | List saves and free slots. |
| `japs1me convert <INPUT.gme> <OUTPUT.srm>` | Convert a DexDrive `.gme` file to RetroArch `.srm`. |
Examples:
```sh
target/release/japs1me list save-games/pro-evolution-soccer-2.22507.gme
target/release/japs1me convert save-games/pro-evolution-soccer-2.22507.gme pes2.srm
```
### `list`
- Accepts `.gme` files and raw images (`.srm`, `.mcr`, …) detected by size and `MC` signature.
- Shows each save's 1-based slot, save name, game title (Shift-JIS decoded, falling back to the save name), region, block count, payload size, and active/deleted status.
- The footer reports free slots out of 15. Directory checksum mismatches and orphaned links are reported as warnings on stderr.
### `convert`
- Accepts DexDrive `.gme` input only (134976 bytes with an `MC` signature at offset 3904). Files with a missing/zeroed header are accepted with a warning.
- Writes the raw 131072-byte payload verbatim via a temporary file; existing output files are not overwritten.
## Safety
- Back up saves before converting.
- `convert` refuses to overwrite; delete or rename a previous `.srm` first if you intend to replace it.
- Avoid testing against your only copy of a save; the fixture in `save-games/` is read-only test input.
## Scope compared with MemcardRex
JAPS1ME focuses on `.gme` → `.srm` conversion and save listing rather than general memory-card editing:
- `list` covers saves and free slots; there is no per-frame browser.
- `convert` handles DexDrive `.gme` only; there is no VGS/VMP/MCX support and no reverse conversion.
- There is no GUI, save import/export of single saves (`.mcs`/`.psx`), deletion, formatting, comment editing, or hardware (DexDrive/serial) support. JAPS1ME validates directory checksums read-only and never recalculates them.
For details about the card formats, see the [technical format guide](docs/FORMATS.md). The [save-games collection](save-games/README.md) lists the saves shared in this repository.
## Tests
Run formatting, tests, lint checks, and a release build from the repository root:
```sh
cargo fmt --check
cargo test
cargo clippy --all-targets -- -D warnings
cargo build --release
```
## License
JAPS1ME is licensed under the **GNU General Public License, version 3 or (at your option) any later version** (SPDX: `GPL-3.0-or-later`). This project license does not automatically apply to the individual save files in `save-games/`; see that directory's README for contribution and attribution guidance.
## Acknowledgements
Thanks to [ShendoXT and the MemcardRex project](https://github.com/ShendoXT/memcardrex), an advanced PlayStation 1 memory-card editor. Its implementation provided a valuable technical reference for understanding the PS1 memory-card and DexDrive formats.

76
docs/FORMATS.md Normal file
View file

@ -0,0 +1,76 @@
# PS1 Save and Memory-Card Formats
This guide describes the formats JAPS1ME currently reads or writes. The notes
were informed by the [MemcardRex project](https://github.com/ShendoXT/memcardrex),
which is also credited in the main README. Corresponding JAPS1ME
implementations: `src/gme.rs` and `src/ps1/`.
## DexDrive `.gme`
A `.gme` file is a 3904-byte header followed by a 131072-byte raw card image
(134976 bytes total). JAPS1ME identifies the format by length plus the `MC`
signature at offset 3904.
### Header
| Offset | Size | Meaning |
| --- | ---: | --- |
| `0x00` | 11 | Magic: `123-456-STD` (canonical files) |
| `0x12`–`0x15` | — | Version/status bytes written by MemcardRex (`0x01`, `0x01`/`M`) |
| `0x16`–`0x25` | 15 | Per-slot type bytes (copies of each directory frame's type) |
| `0x26`–`0x35` | 15 | Per-slot next-pointer bytes |
| `0x40`–`0xF3F` | 15 × 256 | Per-slot comments (system-codepage text, NUL-terminated) |
Some files in the wild have a zeroed or otherwise non-canonical header. Like
MemcardRex, JAPS1ME accepts those when the size is 134976 and bytes at 3904
are `MC`, and reports a `missing DexDrive header magic` warning. Comments are
decoded as Windows-1252 and are informational only; `convert` never modifies
them.
## Raw card image (`.srm`/`.mcr`)
The raw image is 131072 bytes (1024 frames of 128 bytes). RetroArch `.srm`,
ePSXe `.mcr`, and similar formats share this layout; only the file extension
differs. `convert` writes exactly the `.gme` payload bytes `3904..` with no
modification, so directory checksums and save data are preserved verbatim.
### Geometry
| Raw offset | Size | Meaning |
| --- | ---: | --- |
| `0x00` | 128 | Card header frame (`MC` magic) |
| `0x80`–`0x7FF` | 15 × 128 | Directory frames (slots 1–15) |
| `0x2000`– | 15 × 8192 | Save data blocks |
### Directory frames
Each 128-byte frame describes one slot. Relevant fields:
| Offset | Size | Meaning |
| --- | ---: | --- |
| `0x00` | 1 | Slot type: `0xA0` formatted, `0x51` initial, `0x52`/`0x53` middle/end link, `0xA1`–`0xA3` deleted variants |
| `0x04` | 3 | Payload size, little-endian |
| `0x08` | 1 | Next-slot pointer (`0xFF` = end of chain) |
| `0x0A` | 2 | Region (`BA` America, `BE` Europe, `BI` Japan) |
| `0x0C` | 10 | Product code |
| `0x16` | 8 | Save identifier |
| `0x7F` | 1 | XOR checksum over bytes `0x00..0x7F` |
A save is an initial slot plus its link chain (up to 15 slots). Links not
reachable from any initial slot are orphaned; `list` reports them and counts
them as free, matching MemcardRex's broken-link handling.
### Save titles
Each data block carries a 64-byte Shift-JIS title at block offset 4,
NUL-terminated on an even byte boundary. JAPS1ME decodes it with a
malformed-sequence replacement and falls back to the save name
(`region + product code + identifier`) when no usable title exists.
## References
- [MemcardRex](https://github.com/ShendoXT/memcardrex): `MemcardRex.Core/ps1card.cs`
(`getGmeHeader`, `OpenMemoryCard`, `calculateXOR`, `loadStringData`,
`FindSaveLinks`) and `MemcardRex.Core/Hardware/DexDrive.cs` (128-byte frame
protocol and XOR framing on the wire).
- The corresponding JAPS1ME implementations: `src/gme.rs`, `src/ps1/`.

34
save-games/README.md Normal file
View file

@ -0,0 +1,34 @@
# Community Save Games
This directory holds PlayStation 1 save containers shared for community use.
Files remain in their original DexDrive `.gme` container format.
## Collection
| File | Format |
| --- | --- |
| `pro-evolution-soccer-2.22507.gme` | DexDrive (`.gme`) — Pro Evolution Soccer 2 (2 saves: `BESLES-03946PES-OPT`, `BESLES-03946PES-D2A`) |
## Using a save
Convert a save to RetroArch format with JAPS1ME:
```sh
japs1me convert save-games/pro-evolution-soccer-2.22507.gme pes2.srm
```
Back up any existing `.srm` first. `convert` never overwrites an existing
output file.
## Adding saves
When contributing a save, keep its original `.gme` container and use a
descriptive filename. Include the game, region or product code when known, a
brief description, and where the save came from. Only contribute files you
created or have permission to redistribute. Do not include personal
information or credentials in save data.
The JAPS1ME project is GPL-3.0-or-later, but that license does **not**
automatically apply to save files in this directory. No separate license or
redistribution rights are implied for an individual save; retain any
attribution, terms, and permissions that apply to its source.

Binary file not shown.

144
src/commands/list.rs Normal file
View file

@ -0,0 +1,144 @@
//! Unicode table rendering for `japs1me list`.
use crate::ps1::card::SlotInfo;
use unicode_width::UnicodeWidthStr;
const HEADERS: [&str; 7] = [
"Slot", "Save", "Title", "Region", "Blocks", "Size", "Status",
];
/// Format a byte count with binary units.
pub fn format_size(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut value = bytes as f64 / 1024.0;
let mut unit = 1;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
format!("{value:.1} {}", UNITS[unit])
}
fn pad_cell(content: &str, width: usize, right_align: bool) -> String {
let content_width = UnicodeWidthStr::width(content);
let padding = width.saturating_sub(content_width);
if right_align {
format!("{}{content}", " ".repeat(padding))
} else {
format!("{content}{}", " ".repeat(padding))
}
}
/// Render saves as a Unicode table with a free-slot footer.
pub fn format_save_table(saves: &[SlotInfo], free_slots: usize) -> String {
let rows: Vec<[String; 7]> = saves
.iter()
.map(|save| {
[
(save.slot + 1).to_string(),
save.save_name.clone(),
save.game_title.clone(),
save.region.clone(),
save.blocks.to_string(),
format_size(save.size_bytes as u64),
if save.deleted {
"deleted".to_owned()
} else {
"active".to_owned()
},
]
})
.collect();
let mut widths = HEADERS.map(UnicodeWidthStr::width);
for row in &rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(UnicodeWidthStr::width(cell.as_str()));
}
}
let border = |left: &str, middle: &str, right: &str| -> String {
let mut line = String::from(left);
for (index, width) in widths.iter().enumerate() {
if index > 0 {
line.push_str(middle);
}
line.push_str(&"─".repeat(width + 2));
}
line.push_str(right);
line
};
let row_line = |cells: &[String; 7]| -> String {
let mut line = String::from("│");
for (index, cell) in cells.iter().enumerate() {
let right_align = matches!(index, 0 | 4 | 5);
line.push(' ');
line.push_str(&pad_cell(cell, widths[index], right_align));
line.push_str(" │");
}
line
};
let header_cells = HEADERS.map(String::from);
let mut output = String::new();
output.push_str(&border("┌", "┬", "┐"));
output.push('\n');
output.push_str(&row_line(&header_cells));
output.push('\n');
output.push_str(&border("├", "┼", "┤"));
output.push('\n');
for row in &rows {
output.push_str(&row_line(row));
output.push('\n');
}
output.push_str(&border("└", "┴", "┘"));
output.push('\n');
output.push_str(&format!("Free slots: {free_slots} of 15"));
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ps1::card::SlotInfo;
fn sample_save() -> SlotInfo {
SlotInfo {
slot: 0,
save_name: "BESLES-03946PES-OPT".to_owned(),
region: "Europe".to_owned(),
game_title: "PES Option File".to_owned(),
blocks: 2,
size_bytes: 16384,
deleted: false,
}
}
#[test]
fn renders_table_and_footer() {
let table = format_save_table(&[sample_save()], 11);
assert!(table.contains("┌"));
assert!(table.contains("Slot"));
assert!(table.contains("BESLES-03946PES-OPT"));
assert!(table.contains("PES Option File"));
assert!(table.contains("Free slots: 11 of 15"));
}
#[test]
fn renders_empty_card() {
let table = format_save_table(&[], 15);
assert!(table.contains("Slot"));
assert!(table.contains("Free slots: 15 of 15"));
}
#[test]
fn marks_deleted_saves() {
let mut save = sample_save();
save.deleted = true;
let table = format_save_table(&[save], 13);
assert!(table.contains("deleted"));
}
}

4
src/commands/mod.rs Normal file
View file

@ -0,0 +1,4 @@
//! Unicode table rendering for `japs1me list`.
pub mod list;
pub use list::{format_save_table, format_size};

168
src/gme.rs Normal file
View file

@ -0,0 +1,168 @@
//! DexDrive `.gme` memory-card image parsing.
//!
//! A `.gme` file is a 3904-byte header followed by a 131072-byte raw card
//! image, for a total of 134976 bytes. The canonical header starts with the
//! `123-456-STD` magic and carries per-slot status bytes and 256-byte
//! comments. Some files in the wild (including the PES 2 fixture in
//! `save-games/`) have a zeroed or otherwise non-canonical header; those are
//! accepted as long as the embedded raw image starts with `MC`, matching
//! MemcardRex's corrupted-header fallback.
use anyhow::{bail, Result};
/// Length of the DexDrive header prepended to the raw card image.
pub const GME_HEADER_LEN: usize = 3904;
/// Length of the raw card image (also the `.srm` length).
pub const RAW_CARD_LEN: usize = 131072;
/// Total length of a `.gme` file.
pub const GME_FILE_LEN: usize = GME_HEADER_LEN + RAW_CARD_LEN;
/// Canonical DexDrive header magic (`123-456-STD`).
pub const GME_MAGIC: &[u8; 11] = b"123-456-STD";
/// Offset of the per-slot comments within the header.
const COMMENT_OFFSET: usize = 64;
/// Length of each per-slot comment in the header.
const COMMENT_LEN: usize = 256;
/// A parsed DexDrive image.
pub struct GmeCard {
/// True when the header carries the canonical `123-456-STD` magic.
pub header_present: bool,
/// Per-slot comments decoded from the header (empty for `.srm`/zeroed headers).
pub comments: [String; 15],
/// The raw 131072-byte card image.
pub raw: Vec<u8>,
}
/// Parse `.gme` bytes, returning the card plus non-fatal warnings.
///
/// Warnings cover the missing-header fallback; hard errors cover wrong file
/// length and a missing `MC` signature at the raw offset.
pub fn parse_gme(bytes: &[u8]) -> Result<(GmeCard, Vec<String>)> {
let mut warnings = Vec::new();
if bytes.len() != GME_FILE_LEN {
bail!(
"not a DexDrive .gme file: expected {} bytes, found {}",
GME_FILE_LEN,
bytes.len()
);
}
let header = &bytes[..GME_HEADER_LEN];
let raw = &bytes[GME_HEADER_LEN..];
if raw[0] != b'M' || raw[1] != b'C' {
bail!("not a DexDrive .gme file: missing MC signature at raw offset");
}
let header_present = header[..GME_MAGIC.len()] == GME_MAGIC[..];
if !header_present {
warnings.push(
"missing DexDrive header magic (headerless or zeroed header); treating bytes 3904.. as the raw card".to_owned(),
);
}
let mut comments: [String; 15] = Default::default();
for (slot, comment) in comments.iter_mut().enumerate() {
let start = COMMENT_OFFSET + slot * COMMENT_LEN;
*comment = decode_comment(&header[start..start + COMMENT_LEN]);
}
Ok((
GmeCard {
header_present,
comments,
raw: raw.to_vec(),
},
warnings,
))
}
/// Decode one 256-byte header comment using Windows-1252 (MemcardRex uses the
/// system codepage, which is Windows-1252 on Western systems).
fn decode_comment(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (text, _, _) = encoding_rs::WINDOWS_1252.decode(&bytes[..end]);
text.trim_end_matches(['\0', ' ', '\t', '\r', '\n'])
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_raw() -> Vec<u8> {
let mut raw = vec![0u8; RAW_CARD_LEN];
raw[0] = b'M';
raw[1] = b'C';
// One formatted directory frame with a valid XOR checksum.
let off = 128;
raw[off] = 0xA0;
raw[off + 8] = 0xFF;
raw[off + 9] = 0xFF;
let mut xor = 0u8;
for b in &raw[off..off + 127] {
xor ^= b;
}
raw[off + 127] = xor;
raw
}
fn canonical_header() -> Vec<u8> {
let mut header = vec![0u8; GME_HEADER_LEN];
header[..GME_MAGIC.len()].copy_from_slice(GME_MAGIC);
header
}
#[test]
fn parses_canonical_header_without_warnings() {
let mut file = canonical_header();
file.extend_from_slice(&sample_raw());
let (card, warnings) = parse_gme(&file).expect("canonical file parses");
assert!(card.header_present);
assert!(warnings.is_empty());
assert_eq!(card.raw.len(), RAW_CARD_LEN);
assert_eq!(&card.raw[0..2], b"MC");
}
#[test]
fn accepts_headerless_file_with_warning() {
let mut file = vec![0u8; GME_HEADER_LEN];
file.extend_from_slice(&sample_raw());
let (card, warnings) = parse_gme(&file).expect("headerless file parses");
assert!(!card.header_present);
assert_eq!(warnings.len(), 1);
assert_eq!(card.raw.len(), RAW_CARD_LEN);
}
#[test]
fn rejects_wrong_length() {
assert!(parse_gme(&[0u8; RAW_CARD_LEN]).is_err());
assert!(parse_gme(&[0u8; 100]).is_err());
}
#[test]
fn rejects_missing_mc_signature() {
let mut file = canonical_header();
file.extend_from_slice(&vec![0u8; RAW_CARD_LEN]);
assert!(parse_gme(&file).is_err());
}
#[test]
fn decodes_header_comments() {
let mut header = canonical_header();
header[COMMENT_OFFSET..COMMENT_OFFSET + 5].copy_from_slice(b"Hello");
let mut file = header;
file.extend_from_slice(&sample_raw());
let (card, _) = parse_gme(&file).expect("parses");
assert_eq!(card.comments[0], "Hello");
assert_eq!(card.comments[1], "");
}
const PES2_GME: &[u8] = include_bytes!("../save-games/pro-evolution-soccer-2.22507.gme");
#[test]
fn pes2_fixture_strips_to_raw() {
assert_eq!(PES2_GME.len(), GME_FILE_LEN);
let (card, warnings) = parse_gme(PES2_GME).expect("fixture parses");
assert!(!card.header_present);
assert_eq!(warnings.len(), 1);
assert_eq!(&card.raw[0..2], b"MC");
assert_eq!(card.raw.len(), RAW_CARD_LEN);
assert_eq!(card.raw, &PES2_GME[GME_HEADER_LEN..]);
}
}

14
src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
//! JAPS1ME - PS1 RetroArch Save Converter
//!
//! A library for reading DexDrive (`.gme`) and raw (`.srm`/`.mcr`) PlayStation 1
//! memory-card images, with support for converting `.gme` files to RetroArch
//! `.srm` format.
pub mod commands;
pub mod gme;
pub mod ps1;
pub use commands::list::{format_save_table, format_size};
pub use gme::{parse_gme, GmeCard, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
pub use ps1::card::{parse_raw, CardSummary, SlotInfo, SlotType};
pub use ps1::title::decode_title;

103
src/main.rs Normal file
View file

@ -0,0 +1,103 @@
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use japs1me::gme::{parse_gme, GME_FILE_LEN, GME_HEADER_LEN, RAW_CARD_LEN};
use japs1me::{format_save_table, parse_raw};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "japs1me",
version,
about = "JAPS1ME - PS1 RetroArch Save Converter (DexDrive .gme to RetroArch .srm)"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// List saves on a .gme or raw (.srm/.mcr) card image.
List { card: String },
/// Convert a DexDrive .gme file to a RetroArch .srm file.
Convert { input: String, output: String },
}
fn main() -> Result<()> {
match Cli::parse().command {
Command::List { card } => {
let bytes = std::fs::read(&card)
.with_context(|| format!("reading {}", Path::new(&card).display()))?;
let (raw, warnings) = load_raw(&bytes, &card)?;
for warning in warnings {
eprintln!("warning: {warning}");
}
let summary = parse_raw(&raw)?;
for warning in &summary.warnings {
eprintln!("warning: {warning}");
}
println!("{}", format_save_table(&summary.saves, summary.free_slots));
}
Command::Convert { input, output } => {
let bytes = std::fs::read(&input)
.with_context(|| format!("reading {}", Path::new(&input).display()))?;
if bytes.len() != GME_FILE_LEN {
bail!(
"not a DexDrive .gme file: expected {GME_FILE_LEN} bytes, found {}",
bytes.len()
);
}
let (card, warnings) = parse_gme(&bytes)?;
for warning in warnings {
eprintln!("warning: {warning}");
}
let summary = parse_raw(&card.raw)?;
for warning in &summary.warnings {
eprintln!("warning: {warning}");
}
write_output_atomically(&output, &card.raw)?;
eprintln!(
"Converted {} ({} save(s)) to {}",
Path::new(&input).display(),
summary.saves.len(),
Path::new(&output).display()
);
}
}
Ok(())
}
/// Load a raw card image from either `.gme` (stripped) or raw (`.srm`/`.mcr`) bytes.
fn load_raw(bytes: &[u8], name: &str) -> Result<(Vec<u8>, Vec<String>)> {
if bytes.len() == GME_FILE_LEN
&& bytes[GME_HEADER_LEN] == b'M'
&& bytes[GME_HEADER_LEN + 1] == b'C'
{
let (card, warnings) = parse_gme(bytes)?;
return Ok((card.raw, warnings));
}
if bytes.len() == RAW_CARD_LEN && bytes[0] == b'M' && bytes[1] == b'C' {
return Ok((bytes.to_vec(), Vec::new()));
}
bail!(
"unsupported card format for '{name}': expected a .gme file ({GME_FILE_LEN} bytes) or a raw .srm image ({RAW_CARD_LEN} bytes starting with MC)"
)
}
fn write_output_atomically(output: &str, data: &[u8]) -> Result<()> {
use std::io::Write;
let output = PathBuf::from(output);
if output.symlink_metadata().is_ok() {
bail!("output already exists: {}", output.display());
}
let parent = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let mut temp = tempfile::NamedTempFile::new_in(parent.unwrap_or_else(|| Path::new(".")))
.context("creating temporary output file")?;
temp.write_all(data)
.context("writing temporary output file")?;
temp.persist(&output)
.with_context(|| format!("writing {}", output.display()))?;
Ok(())
}

385
src/ps1/card.rs Normal file
View file

@ -0,0 +1,385 @@
//! Raw PS1 memory-card parsing (`.srm`/`.mcr` and stripped `.gme` payloads).
//!
//! Layout (all offsets into the 131072-byte raw image):
//! - Bytes `0..128`: card header frame (`MC` magic).
//! - Bytes `128..2048`: 15 directory frames of 128 bytes.
//! - Bytes `8192..`: 15 save data blocks of 8192 bytes.
//!
//! Directory frame fields used here: type byte at 0, 3-byte little-endian
//! payload size at 4..7, next-slot pointer at 8, region at 10..12, product
//! code at 12..22, identifier at 22..30, and an XOR checksum at 127 covering
//! bytes `0..127` (see MemcardRex `calculateXOR`).
use crate::gme::RAW_CARD_LEN;
use crate::ps1::title::decode_title;
use anyhow::{bail, Result};
/// Number of save slots on a PS1 card.
pub const SLOT_COUNT: usize = 15;
/// Length of one directory frame.
pub const DIR_FRAME_LEN: usize = 128;
/// Length of one save data block.
pub const SAVE_BLOCK_LEN: usize = 8192;
/// Offset of the directory frames in the raw image.
const DIR_FRAMES_OFFSET: usize = 128;
/// Offset of the save data blocks in the raw image.
const SAVE_DATA_OFFSET: usize = 8192;
/// Classification of a directory-frame slot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlotType {
Formatted,
Initial,
MiddleLink,
EndLink,
DeletedInitial,
DeletedMiddleLink,
DeletedEndLink,
Corrupted,
}
impl SlotType {
fn from_byte(byte: u8) -> Self {
match byte {
0xA0 => Self::Formatted,
0x51 => Self::Initial,
0x52 => Self::MiddleLink,
0x53 => Self::EndLink,
0xA1 => Self::DeletedInitial,
0xA2 => Self::DeletedMiddleLink,
0xA3 => Self::DeletedEndLink,
_ => Self::Corrupted,
}
}
fn is_initial(self) -> bool {
matches!(self, Self::Initial | Self::DeletedInitial)
}
fn is_link(self) -> bool {
matches!(
self,
Self::MiddleLink | Self::EndLink | Self::DeletedMiddleLink | Self::DeletedEndLink
)
}
fn is_deleted(self) -> bool {
matches!(
self,
Self::DeletedInitial | Self::DeletedMiddleLink | Self::DeletedEndLink
)
}
}
struct DirFrame {
slot_type: SlotType,
next: u8,
region_raw: String,
product_code: String,
identifier: String,
}
/// One save (an initial slot plus its linked blocks).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotInfo {
/// Zero-based initial slot.
pub slot: usize,
/// Save name (`region + product code + identifier`, e.g. `BESLES-03946PES-OPT`).
pub save_name: String,
/// Human-readable region (`America`/`Europe`/`Japan`, else the raw code).
pub region: String,
/// Game title decoded from the save data (falls back to the save name).
pub game_title: String,
/// Number of slots (blocks) the save occupies.
pub blocks: usize,
/// Payload size in bytes (`blocks * 8192`).
pub size_bytes: usize,
/// True when the save is marked deleted.
pub deleted: bool,
}
/// Parsed card: saves in slot order plus free-slot count and warnings.
pub struct CardSummary {
pub saves: Vec<SlotInfo>,
pub free_slots: usize,
pub warnings: Vec<String>,
}
/// Parse a 131072-byte raw card image.
pub fn parse_raw(raw: &[u8]) -> Result<CardSummary> {
if raw.len() != RAW_CARD_LEN {
bail!(
"not a raw PS1 card image: expected {} bytes, found {}",
RAW_CARD_LEN,
raw.len()
);
}
if raw[0] != b'M' || raw[1] != b'C' {
bail!("not a raw PS1 card image: missing MC signature");
}
let mut frames = Vec::with_capacity(SLOT_COUNT);
let mut warnings = Vec::new();
for slot in 0..SLOT_COUNT {
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
let frame = &raw[start..start + DIR_FRAME_LEN];
let mut xor = 0u8;
for byte in &frame[..DIR_FRAME_LEN - 1] {
xor ^= byte;
}
if xor != frame[DIR_FRAME_LEN - 1] {
warnings.push(format!("slot {}: directory checksum mismatch", slot + 1));
}
frames.push(DirFrame {
slot_type: SlotType::from_byte(frame[0]),
next: frame[8],
region_raw: decode_ascii(&frame[10..12]),
product_code: decode_ascii(&frame[12..22]),
identifier: decode_ascii(&frame[22..30]),
});
}
// Follow link chains from each initial slot.
let mut reachable = [false; SLOT_COUNT];
let mut saves = Vec::new();
for slot in 0..SLOT_COUNT {
if !frames[slot].slot_type.is_initial() {
continue;
}
let chain = find_links(&frames, slot);
for &linked in &chain {
reachable[linked] = true;
}
let frame = &frames[slot];
let save_name = format!(
"{}{}{}",
frame.region_raw, frame.product_code, frame.identifier
);
let data_start = SAVE_DATA_OFFSET + slot * SAVE_BLOCK_LEN;
let title_field = &raw[data_start + 4..data_start + 4 + crate::ps1::title::TITLE_LEN];
let game_title = decode_title(title_field).unwrap_or_else(|| save_name.clone());
let blocks = chain.len();
saves.push(SlotInfo {
slot,
save_name,
region: display_region(&frame.region_raw),
game_title,
blocks,
size_bytes: blocks * SAVE_BLOCK_LEN,
deleted: frame.slot_type.is_deleted(),
});
}
// Slots never reached from an initial slot but typed as links hold stale
// data (broken links); MemcardRex reclassifies those as free.
let mut free_slots = 0;
for slot in 0..SLOT_COUNT {
match frames[slot].slot_type {
SlotType::Formatted => free_slots += 1,
slot_type if slot_type.is_link() && !reachable[slot] => {
free_slots += 1;
warnings.push(format!("slot {}: orphaned link treated as free", slot + 1));
}
_ => {}
}
}
Ok(CardSummary {
saves,
free_slots,
warnings,
})
}
/// Follow a save's link chain, with bounds mirroring MemcardRex `FindSaveLinks`.
fn find_links(frames: &[DirFrame], start: usize) -> Vec<usize> {
let mut chain = vec![start];
let mut current = start;
for _ in 0..SLOT_COUNT {
let next = frames[current].next;
if next == 0xFF {
break;
}
let next = next as usize;
if next >= SLOT_COUNT {
break;
}
if !frames[next].slot_type.is_link() {
break;
}
let end = matches!(
frames[next].slot_type,
SlotType::EndLink | SlotType::DeletedEndLink
);
chain.push(next);
current = next;
if end {
break;
}
}
chain
}
fn decode_ascii(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
bytes[..end]
.iter()
.map(|&b| {
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
' '
}
})
.collect::<String>()
.trim()
.to_owned()
}
fn display_region(raw: &str) -> String {
match raw {
"BA" => "America".to_owned(),
"BE" => "Europe".to_owned(),
"BI" => "Japan".to_owned(),
other => other.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn blank_raw() -> Vec<u8> {
let mut raw = vec![0u8; RAW_CARD_LEN];
raw[0] = b'M';
raw[1] = b'C';
for slot in 0..SLOT_COUNT {
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
raw[start] = 0xA0;
raw[start + 8] = 0xFF;
raw[start + 9] = 0xFF;
fix_xor(&mut raw, start);
}
raw
}
fn fix_xor(raw: &mut [u8], start: usize) {
let mut xor = 0u8;
for byte in &raw[start..start + DIR_FRAME_LEN - 1] {
xor ^= byte;
}
raw[start + DIR_FRAME_LEN - 1] = xor;
}
fn write_frame(raw: &mut [u8], slot: usize, frame: &[u8; DIR_FRAME_LEN]) {
let start = DIR_FRAMES_OFFSET + slot * DIR_FRAME_LEN;
raw[start..start + DIR_FRAME_LEN].copy_from_slice(frame);
}
fn initial_frame(next: u8, region: &[u8; 2], prod: &[u8], ident: &[u8]) -> [u8; DIR_FRAME_LEN] {
let mut frame = [0u8; DIR_FRAME_LEN];
frame[0] = 0x51;
frame[4] = 0x00;
frame[5] = 0x20;
frame[8] = next;
frame[9] = if next == 0xFF { 0xFF } else { 0x00 };
frame[10..12].copy_from_slice(region);
frame[12..12 + prod.len().min(10)].copy_from_slice(&prod[..prod.len().min(10)]);
frame[22..22 + ident.len().min(8)].copy_from_slice(&ident[..ident.len().min(8)]);
let mut xor = 0u8;
for byte in &frame[..DIR_FRAME_LEN - 1] {
xor ^= byte;
}
frame[DIR_FRAME_LEN - 1] = xor;
frame
}
#[test]
fn parses_empty_card() {
let summary = parse_raw(&blank_raw()).expect("parses");
assert!(summary.saves.is_empty());
assert_eq!(summary.free_slots, SLOT_COUNT);
assert!(summary.warnings.is_empty());
}
#[test]
fn parses_single_block_save() {
let mut raw = blank_raw();
write_frame(
&mut raw,
0,
&initial_frame(0xFF, b"BE", b"SLES-03946", b"PES-OPT"),
);
let data = SAVE_DATA_OFFSET;
raw[data + 4..data + 11].copy_from_slice(b"PES-OPT");
let summary = parse_raw(&raw).expect("parses");
assert_eq!(summary.saves.len(), 1);
let save = &summary.saves[0];
assert_eq!(save.slot, 0);
assert_eq!(save.save_name, "BESLES-03946PES-OPT");
assert_eq!(save.region, "Europe");
assert_eq!(save.game_title, "PES-OPT");
assert_eq!(save.blocks, 1);
assert_eq!(save.size_bytes, SAVE_BLOCK_LEN);
assert!(!save.deleted);
assert_eq!(summary.free_slots, SLOT_COUNT - 1);
}
#[test]
fn follows_multi_block_links() {
let mut raw = blank_raw();
write_frame(
&mut raw,
0,
&initial_frame(1, b"BA", b"SLUS-12345", b"SAVE1"),
);
let mut end = [0u8; DIR_FRAME_LEN];
end[0] = 0x53;
end[8] = 0xFF;
end[9] = 0xFF;
let mut xor = 0u8;
for byte in &end[..DIR_FRAME_LEN - 1] {
xor ^= byte;
}
end[DIR_FRAME_LEN - 1] = xor;
write_frame(&mut raw, 1, &end);
let summary = parse_raw(&raw).expect("parses");
assert_eq!(summary.saves.len(), 1);
assert_eq!(summary.saves[0].blocks, 2);
assert_eq!(summary.free_slots, SLOT_COUNT - 2);
}
#[test]
fn reports_bad_xor_without_failing() {
let mut raw = blank_raw();
let start = DIR_FRAMES_OFFSET;
raw[start + 127] ^= 0xFF;
let summary = parse_raw(&raw).expect("still parses");
assert_eq!(summary.warnings.len(), 1);
assert!(summary.warnings[0].contains("checksum mismatch"));
}
#[test]
fn rejects_bad_input() {
assert!(parse_raw(&[0u8; 100]).is_err());
assert!(parse_raw(&[0u8; RAW_CARD_LEN]).is_err());
}
const PES2_GME: &[u8] = include_bytes!("../../save-games/pro-evolution-soccer-2.22507.gme");
#[test]
fn pes2_fixture_has_two_two_block_saves() {
let raw = &PES2_GME[crate::gme::GME_HEADER_LEN..];
let summary = parse_raw(raw).expect("fixture parses");
assert_eq!(summary.saves.len(), 2);
assert_eq!(summary.saves[0].save_name, "BESLES-03946PES-OPT");
assert_eq!(summary.saves[0].blocks, 2);
assert_eq!(summary.saves[0].region, "Europe");
assert!(!summary.saves[0].game_title.is_empty());
assert_ne!(summary.saves[0].game_title, summary.saves[0].save_name);
assert_eq!(summary.saves[1].save_name, "BESLES-03946PES-D2A");
assert_eq!(summary.saves[1].blocks, 2);
assert_eq!(summary.free_slots, 11);
assert!(summary.warnings.is_empty());
}
}

4
src/ps1/mod.rs Normal file
View file

@ -0,0 +1,4 @@
//! PlayStation 1 memory-card model.
pub mod card;
pub mod title;

65
src/ps1/title.rs Normal file
View file

@ -0,0 +1,65 @@
//! Shift-JIS save-title decoding for PS1 saves.
//!
//! Each save's data block starts with a 64-byte Shift-JIS title at offset 4.
//! The field is NUL-terminated on an even byte boundary (matching MemcardRex's
//! `loadStringData` behavior).
/// Length of the title field in a save data block.
pub const TITLE_LEN: usize = 64;
/// Decode a save title from the 64-byte field at data offset 4.
///
/// Returns `None` when the field carries no usable text.
pub fn decode_title(field: &[u8]) -> Option<String> {
let mut len = field.len().min(TITLE_LEN);
for (index, &byte) in field.iter().enumerate().take(len) {
if index % 2 == 0 && byte == 0 {
len = index;
break;
}
}
let field = &field[..len];
let (text, _, _) = encoding_rs::SHIFT_JIS.decode(field);
let title = text
.trim_matches(|c: char| c == '\0' || c.is_whitespace())
.to_owned();
if title.is_empty() {
None
} else {
Some(title)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_ascii_title() {
let mut field = [0u8; TITLE_LEN];
field[..7].copy_from_slice(b"PES-OPT");
assert_eq!(decode_title(&field).as_deref(), Some("PES-OPT"));
}
#[test]
fn decodes_shift_jis_title() {
// "ゲーム" in Shift-JIS.
let mut field = [0u8; TITLE_LEN];
field[..6].copy_from_slice(&[0x83, 0x51, 0x81, 0x5b, 0x83, 0x80]);
assert_eq!(decode_title(&field).as_deref(), Some("ゲーム"));
}
#[test]
fn returns_none_for_empty_title() {
assert_eq!(decode_title(&[0u8; TITLE_LEN]), None);
}
#[test]
fn stops_at_even_nul() {
let mut field = [0u8; TITLE_LEN];
field[..3].copy_from_slice(b"ABC");
// NUL at even index 4 terminates; trailing bytes are ignored.
field[6..9].copy_from_slice(b"XYZ");
assert_eq!(decode_title(&field).as_deref(), Some("ABC"));
}
}