Initial Commit, supports editing PS2 mem cards and adding codebreaker saves

This commit is contained in:
markmental 2026-10-07 13:13:52 -04:00
commit d4d17ee69d
16 changed files with 1941 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
*.ps2
*.cbs
mymcplusplus-reference/

402
Cargo.lock generated Normal file
View file

@ -0,0 +1,402 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[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 = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[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 = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[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 = "japs2me"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"crc32fast",
"flate2",
"tempfile",
"time",
]
[[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 = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[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 = "powerfmt"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[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 = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[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.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
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 = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[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",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "japs2me"
version = "0.1.0"
edition = "2021"
description = "Just A PS2 Memory Card Editor - A CLI for managing PCSX2 memory card images"
license = "GPL-3.0-or-later"
[dependencies]
clap = { version = "4", features = ["derive"] }
flate2 = "1"
anyhow = "1"
crc32fast = "1"
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
tempfile = "3"
[[bin]]
name = "japs2me"
path = "src/main.rs"
[lib]
name = "japs2me"
path = "src/lib.rs"

57
README.md Normal file
View file

@ -0,0 +1,57 @@
# JAPS2ME
**Just A PS2 Memory Card Editor** is a command-line tool for managing PCSX2 PlayStation 2 memory-card images. It can list and delete save directories and import CodeBreaker `.cbs` saves.
> **Early development:** Back up your memory-card image before using JAPS2ME. Avoid editing a card while it is open in PCSX2. Although imports are staged and validated before replacing the image, keep an independent backup.
## Features
- List save directories in a memory-card image.
- Delete a save directory.
- Import a CodeBreaker `.cbs` save, including its files and metadata.
- Read and update standard PS2 memory-card images with ECC support.
## Build
You need a Rust toolchain with Cargo installed.
```sh
cargo build --release
```
The executable is created at `target/release/japs2me`.
## Usage
```text
japs2me list <CARD.ps2>
japs2me delete <CARD.ps2> <SAVE-DIRECTORY>
japs2me add <CARD.ps2> <SAVE.cbs>
```
Examples:
```sh
target/release/japs2me list Mcd001.ps2
target/release/japs2me add Mcd001.ps2 my-save.cbs
target/release/japs2me delete Mcd001.ps2 BASLUS-20787SD5
```
`list` displays save directory names and their directory-entry counts. `add` uses the directory name stored in the CodeBreaker save and will report an error if that save already exists on the card.
## Tests
Run the test suite and lint checks with:
```sh
cargo test
cargo clippy --all-targets -- -D warnings
```
## License
JAPS2ME is licensed under the **GNU General Public License, version 3 or (at your option) any later version** (SPDX: `GPL-3.0-or-later`).
## Acknowledgements
Thanks to [Adubbz and the mymcplusplus project](https://github.com/Adubbz/mymcplusplus), a Python PS2 memory-card manager. Its implementation provided a valuable technical reference for understanding the PS2 memory-card filesystem format and behavior.

227
src/codebreaker/format.rs Normal file
View file

@ -0,0 +1,227 @@
const RC4S: [u8; 256] = [
0x5f, 0x1f, 0x85, 0x6f, 0x31, 0xaa, 0x3b, 0x18, 0x21, 0xb9, 0xce, 0x1c, 0x07, 0x4c, 0x9c, 0xb4,
0x81, 0xb8, 0xef, 0x98, 0x59, 0xae, 0xf9, 0x26, 0xe3, 0x80, 0xa3, 0x29, 0x2d, 0x73, 0x51, 0x62,
0x7c, 0x64, 0x46, 0xf4, 0x34, 0x1a, 0xf6, 0xe1, 0xba, 0x3a, 0x0d, 0x82, 0x79, 0x0a, 0x5c, 0x16,
0x71, 0x49, 0x8e, 0xac, 0x8c, 0x9f, 0x35, 0x19, 0x45, 0x94, 0x3f, 0x56, 0x0c, 0x91, 0x00, 0x0b,
0xd7, 0xb0, 0xdd, 0x39, 0x66, 0xa1, 0x76, 0x52, 0x13, 0x57, 0xf3, 0xbb, 0x4e, 0xe5, 0xdc, 0xf0,
0x65, 0x84, 0xb2, 0xd6, 0xdf, 0x15, 0x3c, 0x63, 0x1d, 0x89, 0x14, 0xbd, 0xd2, 0x36, 0xfe, 0xb1,
0xca, 0x8b, 0xa4, 0xc6, 0x9e, 0x67, 0x47, 0x37, 0x42, 0x6d, 0x6a, 0x03, 0x92, 0x70, 0x05, 0x7d,
0x96, 0x2f, 0x40, 0x90, 0xc4, 0xf1, 0x3e, 0x3d, 0x01, 0xf7, 0x68, 0x1e, 0xc3, 0xfc, 0x72, 0xb5,
0x54, 0xcf, 0xe7, 0x41, 0xe4, 0x4d, 0x83, 0x55, 0x12, 0x22, 0x09, 0x78, 0xfa, 0xde, 0xa7, 0x06,
0x08, 0x23, 0xbf, 0x0f, 0xcc, 0xc1, 0x97, 0x61, 0xc5, 0x4a, 0xe6, 0xa0, 0x11, 0xc2, 0xea, 0x74,
0x02, 0x87, 0xd5, 0xd1, 0x9d, 0xb7, 0x7e, 0x38, 0x60, 0x53, 0x95, 0x8d, 0x25, 0x77, 0x10, 0x5e,
0x9b, 0x7f, 0xd8, 0x6e, 0xda, 0xa2, 0x2e, 0x20, 0x4f, 0xcd, 0x8f, 0xcb, 0xbe, 0x5a, 0xe0, 0xed,
0x2c, 0x9a, 0xd4, 0xe2, 0xaf, 0xd0, 0xa9, 0xe8, 0xad, 0x7a, 0xbc, 0xa8, 0xf2, 0xee, 0xeb, 0xf5,
0xa6, 0x99, 0x28, 0x24, 0x6c, 0x2b, 0x75, 0x5d, 0xf8, 0xd3, 0x86, 0x17, 0xfb, 0xc0, 0x7b, 0xb3,
0x58, 0xdb, 0xc7, 0x4b, 0xff, 0x04, 0x50, 0xe9, 0x88, 0x69, 0xc9, 0x2a, 0xab, 0xfd, 0x5b, 0x1b,
0x8a, 0xd9, 0xec, 0x27, 0x44, 0x0e, 0x33, 0xc8, 0x6b, 0x93, 0x32, 0x48, 0xb6, 0x30, 0x43, 0xa5,
];
use anyhow::{anyhow, bail, ensure, Context, Result};
use flate2::read::ZlibDecoder;
use std::io::{Cursor, Read};
const MAGIC: &[u8; 4] = b"CFU\0";
const HEADER_MIN: usize = 124;
const MAX_DECOMPRESSED_BODY: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct CodebreakerFileEntry {
pub name: String,
pub data: Vec<u8>,
pub mode: u16,
pub created: [u8; 8],
pub modified: [u8; 8],
}
#[derive(Debug, Clone)]
pub struct CodebreakerSave {
pub directory: String,
pub directory_mode: u16,
pub created: [u8; 8],
pub modified: [u8; 8],
pub title: String,
pub files: Vec<CodebreakerFileEntry>,
}
fn crypt(mut data: Vec<u8>) -> Vec<u8> {
let mut state = RC4S;
let mut j = 0usize;
for (index, byte) in data.iter_mut().enumerate() {
let i = (index + 1) & 0xff;
j = (j + state[i] as usize) & 0xff;
state.swap(i, j);
*byte ^= state[(state[i] as usize + state[j] as usize) & 0xff];
}
data
}
fn read_string(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
String::from_utf8_lossy(&bytes[..end]).into_owned()
}
fn slice<const N: usize>(data: &[u8], offset: usize) -> Result<[u8; N]> {
data.get(
offset
..offset
.checked_add(N)
.ok_or_else(|| anyhow!("offset overflow"))?,
)
.ok_or_else(|| anyhow!("truncated Codebreaker header"))?
.try_into()
.map_err(|_| anyhow!("truncated Codebreaker header"))
}
impl CodebreakerSave {
pub fn parse(data: &[u8]) -> Result<Self> {
ensure!(data.len() >= 12, "Codebreaker file is too short");
ensure!(&data[..4] == MAGIC, "not a Codebreaker save");
let header_len = u32::from_le_bytes(slice::<4>(data, 8)?) as usize;
ensure!(header_len >= HEADER_MIN, "Codebreaker header is too short");
ensure!(header_len <= data.len(), "truncated Codebreaker header");
let decompressed_len = u32::from_le_bytes(slice::<4>(data, 12)?) as usize;
let stored_len = u32::from_le_bytes(slice::<4>(data, 16)?) as usize;
ensure!(
decompressed_len <= MAX_DECOMPRESSED_BODY,
"Codebreaker body exceeds size limit"
);
ensure!(
stored_len == data.len() || stored_len == data.len() - header_len,
"Codebreaker body length does not match header"
);
let encrypted_body = crypt(data[header_len..].to_vec());
let decoder = ZlibDecoder::new(Cursor::new(encrypted_body));
let mut body = Vec::with_capacity(decompressed_len);
decoder
.take((decompressed_len as u64).saturating_add(1))
.read_to_end(&mut body)
.context("decompressing Codebreaker save body")?;
ensure!(
body.len() == decompressed_len,
"decompressed Codebreaker body length mismatch"
);
let directory = read_string(&data[20..52]);
let created = slice::<8>(data, 52)?;
let modified = slice::<8>(data, 60)?;
let raw_directory_mode = u32::from_le_bytes(slice::<4>(data, 76)?) as u16;
let directory_mode = if raw_directory_mode & 0x8030 == 0x8020 {
raw_directory_mode
} else {
0x8427 // RWX | directory | 0400 | exists
};
let title = read_string(&data[92..header_len]);
let mut files = Vec::new();
let mut offset = 0usize;
while offset < body.len() {
ensure!(
body.len() - offset >= 64,
"truncated Codebreaker file header"
);
let created = slice::<8>(&body, offset)?;
let modified = slice::<8>(&body, offset + 8)?;
let size = u32::from_le_bytes(slice::<4>(&body, offset + 16)?) as usize;
let mode = u16::from_le_bytes(slice::<2>(&body, offset + 20)?);
let name = read_string(&body[offset + 32..offset + 64]);
ensure!(
mode & 0x8030 == 0x8010,
"Codebreaker entry is not a regular file: {name}"
);
let data_start = offset
.checked_add(64)
.ok_or_else(|| anyhow!("file offset overflow"))?;
let data_end = data_start
.checked_add(size)
.ok_or_else(|| anyhow!("file size overflow"))?;
let file_data = body
.get(data_start..data_end)
.ok_or_else(|| anyhow!("truncated Codebreaker file data: {name}"))?;
files.push(CodebreakerFileEntry {
name,
data: file_data.to_vec(),
mode,
created,
modified,
});
offset = data_end;
}
if directory.is_empty() {
bail!("Codebreaker save has an empty directory name");
}
Ok(Self {
directory,
directory_mode,
created,
modified,
title,
files,
})
}
pub fn from_file(path: &std::path::Path) -> Result<Self> {
Self::parse(&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?)
}
}
#[cfg(test)]
mod tests {
use super::{crypt, CodebreakerSave};
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::Write;
fn sample_cbs() -> Vec<u8> {
let mut body = vec![0u8; 64];
body[0..8].copy_from_slice(&[0, 1, 2, 3, 4, 5, 0xe8, 0x07]);
body[8..16].copy_from_slice(&[0, 6, 7, 8, 9, 5, 0xe8, 0x07]);
body[16..20].copy_from_slice(&3u32.to_le_bytes());
body[20..22].copy_from_slice(&0x8017u16.to_le_bytes());
body[32..40].copy_from_slice(b"DATA.BIN");
body.extend_from_slice(&[0xaa, 0xbb, 0xcc]);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&body).unwrap();
let compressed = crypt(encoder.finish().unwrap());
let header_len = 124usize;
let mut cbs = vec![0u8; header_len];
cbs[0..4].copy_from_slice(b"CFU\0");
cbs[8..12].copy_from_slice(&(header_len as u32).to_le_bytes());
cbs[12..16].copy_from_slice(&(body.len() as u32).to_le_bytes());
cbs[16..20].copy_from_slice(&((header_len + compressed.len()) as u32).to_le_bytes());
cbs[20..31].copy_from_slice(b"BASLUS-TEST");
cbs[52..60].copy_from_slice(&[0, 1, 2, 3, 4, 5, 0xe8, 0x07]);
cbs[60..68].copy_from_slice(&[0, 6, 7, 8, 9, 5, 0xe8, 0x07]);
cbs[76..80].copy_from_slice(&0x8420u32.to_le_bytes());
cbs[92..101].copy_from_slice(b"Test Save");
cbs.extend_from_slice(&compressed);
cbs
}
#[test]
fn parses_header_metadata_and_file_record() {
let save = CodebreakerSave::parse(&sample_cbs()).unwrap();
assert_eq!(save.directory, "BASLUS-TEST");
assert_eq!(save.title, "Test Save");
assert_eq!(save.directory_mode, 0x8420);
assert_eq!(save.files.len(), 1);
assert_eq!(save.files[0].name, "DATA.BIN");
assert_eq!(save.files[0].data, [0xaa, 0xbb, 0xcc]);
assert_eq!(save.files[0].mode, 0x8017);
}
#[test]
fn rejects_truncated_or_inconsistent_cbs() {
assert!(CodebreakerSave::parse(b"CFU\0").is_err());
let mut cbs = sample_cbs();
cbs.truncate(100);
assert!(CodebreakerSave::parse(&cbs).is_err());
}
}

1
src/codebreaker/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod format;

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

@ -0,0 +1 @@

11
src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
//! JAPS2ME - Just A PS2 Memory Card Editor
//!
//! A library for reading and writing PCSX2 memory card images (.ps2 files),
//! with support for Codebreaker save files (.cbs).
pub mod codebreaker;
pub mod commands;
pub mod memory_card;
pub use codebreaker::format::{CodebreakerFileEntry, CodebreakerSave};
pub use memory_card::card::{ImportFile, MemoryCard};

57
src/main.rs Normal file
View file

@ -0,0 +1,57 @@
use anyhow::Result;
use clap::{Parser, Subcommand};
use japs2me::memory_card::transaction::add_directory_atomically;
use japs2me::{CodebreakerSave, ImportFile, MemoryCard};
use std::path::Path;
#[derive(Parser)]
#[command(name = "japs2me", about = "Just A PS2 Memory Card Editor")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
List { card: String },
Delete { card: String, name: String },
Add { card: String, save: String },
}
fn main() -> Result<()> {
match Cli::parse().command {
Command::List { card } => {
let mut card = MemoryCard::open(card, false)?;
for entry in card.entries()? {
println!("{} {} entries", entry.name, entry.length);
}
}
Command::Delete { card, name } => {
let mut card = MemoryCard::open(card, true)?;
card.remove(&name)?;
}
Command::Add { card, save } => {
let save = CodebreakerSave::from_file(Path::new(&save))?;
let files = save
.files
.iter()
.map(|file| ImportFile {
name: file.name.clone(),
data: file.data.clone(),
mode: file.mode,
created: file.created,
modified: file.modified,
})
.collect::<Vec<_>>();
add_directory_atomically(
&card,
&save.directory,
save.directory_mode,
save.created,
save.modified,
&files,
)?;
}
}
Ok(())
}

766
src/memory_card/card.rs Normal file
View file

@ -0,0 +1,766 @@
use super::directory::{DirEntry, DF_DIR, DF_EXISTS, DF_FILE, DF_RWX, DIRENT_SIZE};
use super::ecc::page_spare;
use super::fat::{ALLOCATED, CHAIN_END, CHAIN_END_UNALLOC};
use super::superblock::Superblock;
use anyhow::{anyhow, bail, ensure, Context, Result};
use std::collections::HashSet;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use time::{OffsetDateTime, UtcOffset};
const DF_0400: u16 = 0x0400;
#[derive(Debug, Clone)]
pub struct ImportFile {
pub name: String,
pub data: Vec<u8>,
pub mode: u16,
pub created: [u8; 8],
pub modified: [u8; 8],
}
pub struct MemoryCard {
file: File,
pub path: PathBuf,
pub superblock: Superblock,
has_ecc: bool,
}
impl MemoryCard {
pub fn open(path: impl AsRef<Path>, write: bool) -> Result<Self> {
let path = path.as_ref().to_path_buf();
let mut file = OpenOptions::new()
.read(true)
.write(write)
.open(&path)
.with_context(|| format!("opening {}", path.display()))?;
let mut data = [0u8; 512];
file.read_exact(&mut data)?;
let superblock = Superblock::from_bytes(&data)?;
ensure!(superblock.page_size > 0, "invalid memory-card page size");
ensure!(
superblock.pages_per_cluster > 0,
"invalid pages per cluster"
);
let pages = u64::from(superblock.clusters_per_card)
.checked_mul(u64::from(superblock.pages_per_cluster))
.ok_or_else(|| anyhow!("memory-card page count overflow"))?;
let data_len = pages * u64::from(superblock.page_size);
let ecc_len = pages * u64::from(superblock.raw_page_size());
let image_len = file.metadata()?.len();
let has_ecc = if image_len >= ecc_len {
true
} else if image_len >= data_len {
false
} else {
bail!(
"memory-card image is truncated ({image_len} bytes; expected at least {data_len})"
);
};
Ok(Self {
file,
path,
superblock,
has_ecc,
})
}
fn read_cluster(&mut self, cluster: u32) -> Result<Vec<u8>> {
ensure!(
cluster < self.superblock.allocatable_cluster_end,
"cluster out of range: {cluster}"
);
self.read_raw_cluster(cluster + self.superblock.allocatable_cluster_offset)
}
fn write_cluster(&mut self, cluster: u32, data: &[u8]) -> Result<()> {
ensure!(
cluster < self.superblock.allocatable_cluster_end,
"cluster out of range: {cluster}"
);
self.write_raw_cluster(cluster + self.superblock.allocatable_cluster_offset, data)
}
fn fat_entry(&mut self, cluster: u32) -> Result<u32> {
ensure!(
cluster < self.superblock.allocatable_cluster_end,
"FAT cluster out of range: {cluster}"
);
let entries_per_cluster = self.superblock.entries_per_cluster();
let fat_cluster_index = cluster / entries_per_cluster;
let entry_index = (cluster % entries_per_cluster) as usize;
let indirect_index = (fat_cluster_index / entries_per_cluster) as usize;
ensure!(
indirect_index < self.superblock.indirect_fat_cluster_list.len(),
"indirect FAT index out of range"
);
let indirect_entry_index = (fat_cluster_index % entries_per_cluster) as usize;
let indirect_cluster = self.superblock.indirect_fat_cluster_list[indirect_index];
let indirect = self.read_raw_cluster(indirect_cluster)?;
let fat_cluster = read_u32(&indirect, indirect_entry_index * 4)?;
let fat = self.read_raw_cluster(fat_cluster)?;
read_u32(&fat, entry_index * 4)
}
fn set_fat(&mut self, cluster: u32, value: u32) -> Result<()> {
ensure!(
cluster < self.superblock.allocatable_cluster_end,
"FAT cluster out of range: {cluster}"
);
let entries_per_cluster = self.superblock.entries_per_cluster();
let fat_cluster_index = cluster / entries_per_cluster;
let entry_index = (cluster % entries_per_cluster) as usize;
let indirect_index = (fat_cluster_index / entries_per_cluster) as usize;
ensure!(
indirect_index < self.superblock.indirect_fat_cluster_list.len(),
"indirect FAT index out of range"
);
let indirect_entry_index = (fat_cluster_index % entries_per_cluster) as usize;
let indirect_cluster = self.superblock.indirect_fat_cluster_list[indirect_index];
let indirect = self.read_raw_cluster(indirect_cluster)?;
let fat_cluster = read_u32(&indirect, indirect_entry_index * 4)?;
let mut fat = self.read_raw_cluster(fat_cluster)?;
write_u32(&mut fat, entry_index * 4, value)?;
self.write_raw_cluster(fat_cluster, &fat)
}
fn read_raw_cluster(&mut self, cluster: u32) -> Result<Vec<u8>> {
let page_size = self.superblock.page_size as usize;
let pages_per_cluster = self.superblock.pages_per_cluster as usize;
let stride = if self.has_ecc {
self.superblock.raw_page_size() as u64
} else {
self.superblock.page_size as u64
};
let first_page = u64::from(cluster) * pages_per_cluster as u64;
let mut output = Vec::with_capacity(page_size * pages_per_cluster);
for page_index in 0..pages_per_cluster {
let offset = (first_page + page_index as u64) * stride;
self.file.seek(SeekFrom::Start(offset))?;
let mut page = vec![0; page_size];
self.file.read_exact(&mut page)?;
output.extend_from_slice(&page);
if self.has_ecc {
self.file.seek(SeekFrom::Start(offset + page_size as u64))?;
let mut spare = vec![0; self.superblock.spare_size() as usize];
self.file.read_exact(&mut spare)?;
}
}
Ok(output)
}
fn write_raw_cluster(&mut self, cluster: u32, data: &[u8]) -> Result<()> {
let page_size = self.superblock.page_size as usize;
let pages_per_cluster = self.superblock.pages_per_cluster as usize;
ensure!(
data.len() == page_size * pages_per_cluster,
"cluster write has incorrect length"
);
let stride = if self.has_ecc {
self.superblock.raw_page_size() as u64
} else {
self.superblock.page_size as u64
};
let first_page = u64::from(cluster) * pages_per_cluster as u64;
for page_index in 0..pages_per_cluster {
let page = &data[page_index * page_size..(page_index + 1) * page_size];
let offset = (first_page + page_index as u64) * stride;
self.file.seek(SeekFrom::Start(offset))?;
self.file.write_all(page)?;
if self.has_ecc {
let spare = page_spare(page, self.superblock.spare_size() as usize);
self.file.write_all(&spare)?;
}
}
Ok(())
}
fn chain(&mut self, first: u32) -> Result<Vec<u32>> {
let mut chain = Vec::new();
let mut visited = HashSet::new();
let mut current = first;
while current != CHAIN_END {
ensure!(
current < self.superblock.allocatable_cluster_end,
"FAT chain points outside allocatable area"
);
ensure!(visited.insert(current), "FAT cycle at cluster {current}");
chain.push(current);
let next = self.fat_entry(current)?;
if next == CHAIN_END {
break;
}
ensure!(
next & ALLOCATED != 0,
"invalid FAT link at cluster {current}"
);
current = next & !ALLOCATED;
}
Ok(chain)
}
pub fn read_file(&mut self, entry: &DirEntry) -> Result<Vec<u8>> {
if entry.length == 0 {
return Ok(Vec::new());
}
let mut data = Vec::new();
for cluster in self.chain(entry.first_cluster)? {
data.extend_from_slice(&self.read_cluster(cluster)?);
}
data.truncate(entry.length as usize);
Ok(data)
}
fn allocate_cluster(&mut self) -> Result<u32> {
for cluster in 1..self.superblock.allocatable_cluster_end {
if self.fat_entry(cluster)? & ALLOCATED == 0 {
self.set_fat(cluster, CHAIN_END)?;
return Ok(cluster);
}
}
bail!("memory card is full")
}
fn free_chain(&mut self, first: u32) -> Result<()> {
let chain = self.chain(first)?;
for cluster in chain {
self.set_fat(cluster, CHAIN_END_UNALLOC)?;
}
Ok(())
}
fn write_file_data(&mut self, data: &[u8]) -> Result<u32> {
if data.is_empty() {
return Ok(CHAIN_END);
}
let cluster_size = self.superblock.cluster_size() as usize;
let mut first = CHAIN_END;
let mut previous = None;
for chunk in data.chunks(cluster_size) {
let cluster = self.allocate_cluster()?;
if first == CHAIN_END {
first = cluster;
}
if let Some(previous) = previous {
self.set_fat(previous, cluster | ALLOCATED)?;
}
let mut padded = vec![0; cluster_size];
padded[..chunk.len()].copy_from_slice(chunk);
self.write_cluster(cluster, &padded)?;
previous = Some(cluster);
}
Ok(first)
}
pub fn directory_entries(&mut self, first_cluster: u32, length: u32) -> Result<Vec<DirEntry>> {
let chain = self.chain(first_cluster)?;
let entries_per_cluster = self.superblock.cluster_size() as usize / DIRENT_SIZE;
let capacity = chain.len() * entries_per_cluster;
ensure!(
length as usize <= capacity,
"directory length exceeds its FAT chain capacity"
);
let mut entries = Vec::with_capacity(length as usize);
for cluster in chain {
let data = self.read_cluster(cluster)?;
for chunk in data.chunks_exact(DIRENT_SIZE) {
if entries.len() == length as usize {
return Ok(entries);
}
entries.push(DirEntry::from_bytes(chunk)?);
}
}
Ok(entries)
}
fn write_directory_entry(
&mut self,
first_cluster: u32,
index: usize,
entry: &DirEntry,
) -> Result<()> {
let entries_per_cluster = self.superblock.cluster_size() as usize / DIRENT_SIZE;
let chain = self.chain(first_cluster)?;
let cluster_index = index / entries_per_cluster;
ensure!(
cluster_index < chain.len(),
"directory entry index exceeds allocated chain"
);
let cluster = chain[cluster_index];
let local_index = index % entries_per_cluster;
let mut data = self.read_cluster(cluster)?;
let offset = local_index * DIRENT_SIZE;
data[offset..offset + DIRENT_SIZE].copy_from_slice(&entry.to_bytes());
self.write_cluster(cluster, &data)
}
fn ensure_directory_capacity(&mut self, first_cluster: u32, index: usize) -> Result<()> {
let entries_per_cluster = self.superblock.cluster_size() as usize / DIRENT_SIZE;
let needed = index / entries_per_cluster + 1;
let mut chain = self.chain(first_cluster)?;
while chain.len() < needed {
let new_cluster = self.allocate_cluster()?;
let previous = *chain
.last()
.ok_or_else(|| anyhow!("directory has no starting cluster"))?;
self.set_fat(previous, new_cluster | ALLOCATED)?;
self.write_cluster(
new_cluster,
&vec![0; self.superblock.cluster_size() as usize],
)?;
chain.push(new_cluster);
}
Ok(())
}
fn append_directory_entry(
&mut self,
first_cluster: u32,
entry_count: u32,
is_root: bool,
entry: &DirEntry,
) -> Result<(u32, u32)> {
let mut dot = self
.directory_entries(first_cluster, 2)?
.into_iter()
.next()
.ok_or_else(|| anyhow!("directory is missing its . entry"))?;
ensure!(entry_count >= 2, "directory entry count is invalid");
let entries = self.directory_entries(first_cluster, entry_count)?;
let free_index = entries
.iter()
.enumerate()
.skip(2)
.find_map(|(index, current)| (current.mode & DF_EXISTS == 0).then_some(index));
let index = free_index.unwrap_or(entry_count as usize);
self.ensure_directory_capacity(first_cluster, index)?;
self.write_directory_entry(first_cluster, index, entry)?;
let new_entry_count = if index == entry_count as usize {
entry_count
.checked_add(1)
.ok_or_else(|| anyhow!("directory is too large"))?
} else {
entry_count
};
// For the root directory there is no parent dirent holding its
// length, so the root '.' entry carries that count. For save
// directories the count belongs to the save's root dirent; their
// own '.' entry must retain its reference-format length of zero.
if is_root {
dot.length = new_entry_count;
dot.modified = current_tod();
self.write_directory_entry(first_cluster, 0, &dot)?;
}
Ok((index as u32, new_entry_count))
}
pub fn entries(&mut self) -> Result<Vec<DirEntry>> {
let root_dot = self
.directory_entries(0, 1)?
.into_iter()
.next()
.ok_or_else(|| anyhow!("root directory is empty"))?;
Ok(self
.directory_entries(0, root_dot.length)?
.into_iter()
.skip(2)
.filter(|entry| entry.mode & DF_EXISTS != 0)
.collect())
}
pub(crate) fn add_directory(
&mut self,
name: &str,
directory_mode: u16,
created: [u8; 8],
modified: [u8; 8],
files: &[ImportFile],
) -> Result<()> {
validate_name(name)?;
ensure!(directory_mode & DF_DIR != 0, "save mode is not a directory");
if self.entries()?.iter().any(|entry| entry.name == name) {
bail!("save already exists: {name}");
}
for file in files {
validate_name(&file.name)?;
u32::try_from(file.data.len()).context("file is too large for PS2 directory entry")?;
}
let mut names = HashSet::new();
for file in files {
ensure!(
names.insert(file.name.as_str()),
"duplicate file in save: {}",
file.name
);
}
let mut directory_entries = vec![0; self.superblock.cluster_size() as usize];
let now = current_tod();
let first_directory_cluster = self.allocate_cluster()?;
let dot = DirEntry {
mode: DF_RWX | DF_DIR | DF_0400 | DF_EXISTS,
length: 0,
created: now,
// mymc+ stores the parent directory location in the first entry.
first_cluster: 0,
parent_index: 0,
modified: now,
attr: 0,
name: ".".to_owned(),
};
let dotdot = DirEntry {
mode: DF_RWX | DF_DIR | DF_0400 | DF_EXISTS,
length: 0,
created: now,
first_cluster: 0,
parent_index: 0,
modified: now,
attr: 0,
name: "..".to_owned(),
};
directory_entries[..DIRENT_SIZE].copy_from_slice(&dot.to_bytes());
directory_entries[DIRENT_SIZE..DIRENT_SIZE * 2].copy_from_slice(&dotdot.to_bytes());
self.write_cluster(first_directory_cluster, &directory_entries)?;
let mut directory_entry_count = 2u32;
for file in files {
let first_file_cluster = self.write_file_data(&file.data)?;
let file_entry = DirEntry {
mode: (file.mode & !DF_DIR) | DF_FILE | DF_EXISTS,
length: u32::try_from(file.data.len())
.context("file is too large for PS2 directory entry")?,
created: valid_tod_or_now(file.created),
first_cluster: first_file_cluster,
parent_index: 0,
modified: valid_tod_or_now(file.modified),
attr: 0,
name: file.name.clone(),
};
let (_, new_count) = self.append_directory_entry(
first_directory_cluster,
directory_entry_count,
false,
&file_entry,
)?;
directory_entry_count = new_count;
}
let root_entry = DirEntry {
mode: (directory_mode & !DF_FILE) | DF_DIR | DF_EXISTS,
length: directory_entry_count,
created: valid_tod_or_now(created),
first_cluster: first_directory_cluster,
parent_index: 0,
modified: valid_tod_or_now(modified),
attr: 0,
name: name.to_owned(),
};
let root_length = self.directory_entries(0, 1)?[0].length;
let (root_index, _) = self.append_directory_entry(0, root_length, true, &root_entry)?;
let mut dot = self.directory_entries(first_directory_cluster, 2)?[0].clone();
dot.first_cluster = 0;
dot.parent_index = root_index;
self.write_directory_entry(first_directory_cluster, 0, &dot)?;
self.file.sync_all()?;
Ok(())
}
pub fn remove(&mut self, name: &str) -> Result<()> {
let root_entries = self.entries()?;
let target = root_entries
.into_iter()
.find(|entry| entry.name == name)
.ok_or_else(|| anyhow!("save not found: {name}"))?;
if target.is_dir() {
for child in self
.directory_entries(target.first_cluster, target.length)?
.into_iter()
.skip(2)
{
if child.mode & DF_EXISTS == 0 {
continue;
}
if child.is_dir() {
bail!("refusing to remove nested directory {}", child.name);
}
self.free_chain(child.first_cluster)?;
}
} else {
self.free_chain(target.first_cluster)?;
}
if target.is_dir() {
self.free_chain(target.first_cluster)?;
}
let root_length = self.directory_entries(0, 1)?[0].length;
let root = self.directory_entries(0, root_length)?;
let index = root
.iter()
.position(|entry| entry.name == name && entry.mode & DF_EXISTS != 0)
.ok_or_else(|| anyhow!("save not found: {name}"))?;
let mut deleted = root[index].clone();
deleted.mode &= !DF_EXISTS;
self.write_directory_entry(0, index, &deleted)?;
self.file.sync_all()?;
Ok(())
}
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let bytes = data
.get(offset..offset + 4)
.ok_or_else(|| anyhow!("truncated FAT cluster"))?;
Ok(u32::from_le_bytes(bytes.try_into()?))
}
fn write_u32(data: &mut [u8], offset: usize, value: u32) -> Result<()> {
let bytes = data
.get_mut(offset..offset + 4)
.ok_or_else(|| anyhow!("truncated FAT cluster"))?;
bytes.copy_from_slice(&value.to_le_bytes());
Ok(())
}
fn validate_name(name: &str) -> Result<()> {
ensure!(!name.is_empty(), "directory entry name cannot be empty");
ensure!(
name.len() <= 32,
"PS2 save names must be at most 32 bytes: {name}"
);
ensure!(name.is_ascii(), "PS2 save names must be ASCII: {name}");
ensure!(
!name
.bytes()
.any(|byte| byte == 0 || byte == b'/' || byte == b'\\'),
"invalid character in PS2 save name: {name}"
);
ensure!(
name != "." && name != "..",
"reserved PS2 directory name: {name}"
);
Ok(())
}
fn current_tod() -> [u8; 8] {
let now = OffsetDateTime::now_utc().to_offset(UtcOffset::from_hms(9, 0, 0).unwrap());
let mut tod = [0; 8];
tod[1] = now.second();
tod[2] = now.minute();
tod[3] = now.hour();
tod[4] = now.day();
tod[5] = now.month() as u8;
tod[6..8].copy_from_slice(&(now.year() as u16).to_le_bytes());
tod
}
fn valid_tod_or_now(tod: [u8; 8]) -> [u8; 8] {
if tod[1..].iter().all(|&byte| byte == 0) {
current_tod()
} else {
tod
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn create_test_card(path: &Path, root_has_second_cluster: bool) {
let page_size = 512usize;
let pages_per_cluster = 2usize;
let cluster_size = page_size * pages_per_cluster;
let clusters = 128u32;
let alloc_offset = 4u32;
let alloc_end = 100u32;
let mut image = vec![0u8; clusters as usize * cluster_size];
let superblock = &mut image[..512];
superblock[..28].copy_from_slice(b"Sony PS2 Memory Card Format ");
superblock[28..40].copy_from_slice(b"1.2.0.0\0\0\0\0\0");
put_u16(superblock, 40, page_size as u16);
put_u16(superblock, 42, pages_per_cluster as u16);
put_u16(superblock, 44, 16);
put_u32(superblock, 48, clusters);
put_u32(superblock, 52, alloc_offset);
put_u32(superblock, 56, alloc_end);
put_u32(superblock, 60, 0);
put_u32(superblock, 64, 12);
put_u32(superblock, 68, 11);
put_u32(superblock, 80, 2); // indirect FAT cluster
put_u32(superblock, 84, 0xffff_ffff);
put_u32(superblock, 208, 0xffff_ffff);
let indirect_start = 2 * cluster_size;
put_u32(&mut image, indirect_start, 3); // FAT cluster
let fat_start = 3 * cluster_size;
for cluster in 0..alloc_end as usize {
put_u32(&mut image, fat_start + cluster * 4, CHAIN_END_UNALLOC);
}
put_u32(
&mut image,
fat_start,
if root_has_second_cluster {
ALLOCATED | 1
} else {
CHAIN_END
},
);
put_u32(&mut image, fat_start + 4, CHAIN_END);
let root_first = alloc_offset as usize * cluster_size;
let dot = DirEntry {
mode: DF_RWX | DF_DIR | DF_EXISTS,
length: if root_has_second_cluster { 4 } else { 2 },
created: [0; 8],
first_cluster: 0,
parent_index: 1,
modified: [0; 8],
attr: 0,
name: ".".into(),
};
let dotdot = DirEntry {
mode: DF_RWX | DF_DIR | DF_EXISTS,
length: 0,
created: [0; 8],
first_cluster: 0,
parent_index: 0,
modified: [0; 8],
attr: 0,
name: "..".into(),
};
image[root_first..root_first + DIRENT_SIZE].copy_from_slice(&dot.to_bytes());
image[root_first + DIRENT_SIZE..root_first + 2 * DIRENT_SIZE]
.copy_from_slice(&dotdot.to_bytes());
if root_has_second_cluster {
let second_start = (alloc_offset as usize + 1) * cluster_size;
let first_existing = DirEntry {
mode: DF_RWX | DF_DIR | DF_EXISTS,
length: 2,
created: [0; 8],
first_cluster: 10,
parent_index: 0,
modified: [0; 8],
attr: 0,
name: "EXISTING".into(),
};
image[second_start..second_start + DIRENT_SIZE]
.copy_from_slice(&first_existing.to_bytes());
}
fs::write(path, image).unwrap();
}
fn put_u16(data: &mut [u8], offset: usize, value: u16) {
data[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
}
fn put_u32(data: &mut [u8], offset: usize, value: u32) {
data[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
fn sample_file(name: &str, data: Vec<u8>) -> ImportFile {
ImportFile {
name: name.into(),
data,
mode: DF_FILE | DF_RWX | DF_EXISTS,
created: [0; 8],
modified: [0; 8],
}
}
#[test]
fn imports_directory_and_multicluster_files() -> Result<()> {
let temp = tempdir()?;
let path = temp.path().join("card.ps2");
create_test_card(&path, true);
let payload = (0..2500).map(|n| (n % 251) as u8).collect::<Vec<_>>();
let files = vec![
sample_file("SAVE.DAT", payload.clone()),
sample_file("EMPTY", vec![]),
];
MemoryCard::open(&path, true)?.add_directory(
"BASLUS-TEST",
0x8420,
[0; 8],
[0; 8],
&files,
)?;
let mut card = MemoryCard::open(&path, false)?;
let root = card.entries()?;
let save = root
.iter()
.find(|entry| entry.name == "BASLUS-TEST")
.unwrap();
assert!(save.is_dir());
assert_eq!(save.length, 4);
let children = card.directory_entries(save.first_cluster, save.length)?;
assert_eq!(children[0].name, ".");
assert_eq!(children[0].length, 0);
assert_eq!(children[1].name, "..");
assert_eq!(children[1].length, 0);
assert_eq!(children[0].first_cluster, 0);
let file_entry = children
.iter()
.find(|entry| entry.name == "SAVE.DAT")
.unwrap();
assert_eq!(file_entry.length as usize, payload.len());
assert_eq!(card.read_file(file_entry)?, payload);
let empty = children.iter().find(|entry| entry.name == "EMPTY").unwrap();
assert_eq!(empty.length, 0);
assert_eq!(empty.first_cluster, CHAIN_END);
Ok(())
}
#[test]
fn import_uses_a_free_root_slot_in_later_cluster() -> Result<()> {
let temp = tempdir()?;
let path = temp.path().join("card.ps2");
create_test_card(&path, true);
let files = vec![sample_file("DATA", vec![1, 2, 3])];
MemoryCard::open(&path, true)?.add_directory("NEW-SAVE", 0x8420, [0; 8], [0; 8], &files)?;
let mut card = MemoryCard::open(&path, false)?;
assert!(card.entries()?.iter().any(|entry| entry.name == "NEW-SAVE"));
Ok(())
}
#[test]
fn rejects_duplicate_names_without_changing_image() -> Result<()> {
let temp = tempdir()?;
let path = temp.path().join("card.ps2");
create_test_card(&path, false);
let before = fs::read(&path)?;
let files = vec![sample_file("DATA", vec![1])];
let result = MemoryCard::open(&path, true)?
.add_directory("NEW-SAVE", 0x8420, [0; 8], [0; 8], &files);
assert!(result.is_ok());
let before_duplicate = fs::read(&path)?;
let duplicate = MemoryCard::open(&path, true)?
.add_directory("NEW-SAVE", 0x8420, [0; 8], [0; 8], &files);
assert!(duplicate.is_err());
assert_eq!(fs::read(&path)?, before_duplicate);
assert_ne!(before, before_duplicate);
Ok(())
}
#[test]
fn atomic_import_keeps_original_on_out_of_space() -> Result<()> {
use crate::memory_card::transaction::add_directory_atomically;
let temp = tempdir()?;
let path = temp.path().join("card.ps2");
create_test_card(&path, false);
let original = fs::read(&path)?;
let files = vec![sample_file("TOO-LARGE", vec![0x5a; 200 * 1024])];
let result = add_directory_atomically(&path, "NEW-SAVE", 0x8420, [0; 8], [0; 8], &files);
assert!(result.is_err());
assert_eq!(fs::read(&path)?, original);
Ok(())
}
}

View file

@ -0,0 +1,75 @@
//! PS2 memory-card directory entries.
use anyhow::{anyhow, Result};
pub const DIRENT_SIZE: usize = 512;
pub const DF_READ: u16 = 0x0001;
pub const DF_WRITE: u16 = 0x0002;
pub const DF_EXECUTE: u16 = 0x0004;
pub const DF_RWX: u16 = DF_READ | DF_WRITE | DF_EXECUTE;
pub const DF_FILE: u16 = 0x0010;
pub const DF_DIR: u16 = 0x0020;
pub const DF_EXISTS: u16 = 0x8000;
#[derive(Debug, Clone)]
pub struct DirEntry {
pub mode: u16,
pub length: u32,
pub created: [u8; 8],
pub first_cluster: u32,
pub parent_index: u32,
pub modified: [u8; 8],
pub attr: u32,
pub name: String,
}
impl DirEntry {
pub fn from_bytes(data: &[u8]) -> Result<Self> {
if data.len() < DIRENT_SIZE {
return Err(anyhow!("directory entry too short"));
}
let mode = u16::from_le_bytes([data[0], data[1]]);
let length = u32::from_le_bytes(data[4..8].try_into()?);
let mut created = [0; 8];
created.copy_from_slice(&data[8..16]);
let first_cluster = u32::from_le_bytes(data[16..20].try_into()?);
let parent_index = u32::from_le_bytes(data[20..24].try_into()?);
let mut modified = [0; 8];
modified.copy_from_slice(&data[24..32]);
let attr = u32::from_le_bytes(data[32..36].try_into()?);
let end = data[64..512].iter().position(|&b| b == 0).unwrap_or(448);
let name = String::from_utf8_lossy(&data[64..64 + end]).to_string();
Ok(Self {
mode,
length,
created,
first_cluster,
parent_index,
modified,
attr,
name,
})
}
pub fn to_bytes(&self) -> [u8; DIRENT_SIZE] {
let mut out = [0u8; DIRENT_SIZE];
out[0..2].copy_from_slice(&self.mode.to_le_bytes());
out[4..8].copy_from_slice(&self.length.to_le_bytes());
out[8..16].copy_from_slice(&self.created);
out[16..20].copy_from_slice(&self.first_cluster.to_le_bytes());
out[20..24].copy_from_slice(&self.parent_index.to_le_bytes());
out[24..32].copy_from_slice(&self.modified);
out[32..36].copy_from_slice(&self.attr.to_le_bytes());
let name = self.name.as_bytes();
let n = name.len().min(447);
out[64..64 + n].copy_from_slice(&name[..n]);
out
}
pub fn is_dir(&self) -> bool {
self.mode & (DF_DIR | DF_EXISTS) == (DF_DIR | DF_EXISTS)
}
pub fn is_file(&self) -> bool {
self.mode & (DF_FILE | DF_EXISTS) == (DF_FILE | DF_EXISTS)
}
}

51
src/memory_card/ecc.rs Normal file
View file

@ -0,0 +1,51 @@
//! Hamming ECC used in the spare area of PS2 memory-card pages.
fn parity(value: u8) -> u8 {
(value.count_ones() & 1) as u8
}
fn ecc_128(data: &[u8]) -> [u8; 3] {
let masks = [0x55, 0x33, 0x0f, 0x00, 0xaa, 0xcc, 0xf0];
let mut column = 0x77u8;
let mut line0 = 0x7fu8;
let mut line1 = 0x7fu8;
for (index, &byte) in data.iter().enumerate() {
for (bit, mask) in masks.iter().enumerate() {
column ^= parity(byte & mask) << bit;
}
if parity(byte) != 0 {
line0 ^= !(index as u8);
line1 ^= index as u8;
}
}
[column, line0 & 0x7f, line1]
}
/// Calculate the page spare bytes (three ECC bytes per 128 data bytes,
/// followed by zeroed unused spare bytes).
pub fn page_spare(page: &[u8], spare_len: usize) -> Vec<u8> {
let mut spare = vec![0; spare_len];
for (chunk_index, chunk) in page.chunks(128).enumerate() {
let ecc = ecc_128(chunk);
let start = chunk_index * 3;
if start + 3 <= spare.len() {
spare[start..start + 3].copy_from_slice(&ecc);
}
}
spare
}
#[cfg(test)]
mod tests {
use super::page_spare;
#[test]
fn matches_reference_ecc_for_zero_page() {
assert_eq!(
page_spare(&[0; 512], 16),
[0x77, 0x7f, 0x7f, 0x77, 0x7f, 0x7f, 0x77, 0x7f, 0x7f, 0x77, 0x7f, 0x7f, 0, 0, 0, 0]
);
}
}

15
src/memory_card/fat.rs Normal file
View file

@ -0,0 +1,15 @@
//! FAT constants and chain helpers.
pub const ALLOCATED: u32 = 0x8000_0000;
pub const CHAIN_END: u32 = 0xffff_ffff;
pub const CHAIN_END_UNALLOC: u32 = 0x7fff_ffff;
pub fn next_cluster(value: u32) -> Option<u32> {
if value == CHAIN_END {
None
} else if value & ALLOCATED != 0 {
Some(value & !ALLOCATED)
} else {
None
}
}

8
src/memory_card/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! PS2 memory card filesystem implementation.
pub mod card;
pub mod directory;
pub mod ecc;
pub mod fat;
pub mod superblock;
pub mod transaction;

View file

@ -0,0 +1,184 @@
//! Superblock parsing and writing for PS2 memory cards.
use anyhow::{anyhow, Result};
/// Magic bytes at the start of a PS2 memory card image.
pub const PS2MC_MAGIC: &[u8] = b"Sony PS2 Memory Card Format ";
/// Standard page size for PS2 memory cards.
pub const PS2MC_STANDARD_PAGE_SIZE: u32 = 512;
/// Standard pages per card (8MB card = 16384 pages).
pub const PS2MC_STANDARD_PAGES_PER_CARD: u32 = 16384;
/// Standard pages per erase block.
pub const PS2MC_STANDARD_PAGES_PER_ERASE_BLOCK: u32 = 16;
/// Cluster size (2 pages of 512 bytes).
pub const PS2MC_CLUSTER_SIZE: u32 = 1024;
/// Offset of the indirect FAT in the card.
pub const PS2MC_INDIRECT_FAT_OFFSET: u32 = 0x2000;
/// Maximum number of indirect FAT clusters.
pub const PS2MC_MAX_INDIRECT_FAT_CLUSTERS: usize = 32;
/// The superblock is the first 512 bytes of the memory card.
#[derive(Debug, Clone)]
pub struct Superblock {
/// Magic bytes: "Sony PS2 Memory Card Format "
pub magic: [u8; 28],
/// Version string, e.g. "1.2.0.0"
pub version: [u8; 12],
/// Page size in bytes (typically 512).
pub page_size: u16,
/// Pages per cluster (typically 2).
pub pages_per_cluster: u16,
/// Pages per erase block (typically 16).
pages_per_erase_block: u16,
/// Total clusters per card.
pub clusters_per_card: u32,
/// First allocatable cluster.
pub allocatable_cluster_offset: u32,
/// Last allocatable cluster + 1.
pub allocatable_cluster_end: u32,
/// Root directory FAT cluster (always 0).
pub rootdir_fat_cluster: u32,
/// Good block 1 (for ECC).
pub good_block1: u32,
/// Good block 2 (for ECC).
pub good_block2: u32,
/// Indirect FAT cluster list (up to 32 entries).
pub indirect_fat_cluster_list: [u32; PS2MC_MAX_INDIRECT_FAT_CLUSTERS],
/// Bad erase block list (up to 32 entries).
pub bad_erase_block_list: [u32; PS2MC_MAX_INDIRECT_FAT_CLUSTERS],
}
impl Superblock {
/// Parse a superblock from raw bytes.
pub fn from_bytes(data: &[u8]) -> Result<Self> {
if data.len() < 512 {
return Err(anyhow!("Superblock data too short: {} bytes", data.len()));
}
if !data.starts_with(PS2MC_MAGIC) {
return Err(anyhow!("Not a PS2 memory card image (bad magic)"));
}
let mut magic = [0u8; 28];
magic.copy_from_slice(&data[0..28]);
let mut version = [0u8; 12];
version.copy_from_slice(&data[28..40]);
let page_size = u16::from_le_bytes([data[40], data[41]]);
let pages_per_cluster = u16::from_le_bytes([data[42], data[43]]);
let pages_per_erase_block = u16::from_le_bytes([data[44], data[45]]);
// Skip 2 bytes padding at offset 46-47
let clusters_per_card = u32::from_le_bytes([data[48], data[49], data[50], data[51]]);
let allocatable_cluster_offset =
u32::from_le_bytes([data[52], data[53], data[54], data[55]]);
let allocatable_cluster_end = u32::from_le_bytes([data[56], data[57], data[58], data[59]]);
let rootdir_fat_cluster = u32::from_le_bytes([data[60], data[61], data[62], data[63]]);
let good_block1 = u32::from_le_bytes([data[64], data[65], data[66], data[67]]);
let good_block2 = u32::from_le_bytes([data[68], data[69], data[70], data[71]]);
// Skip 8 bytes padding at offset 72-79
let mut indirect_fat_cluster_list = [0u32; PS2MC_MAX_INDIRECT_FAT_CLUSTERS];
for (i, cluster) in indirect_fat_cluster_list.iter_mut().enumerate() {
let offset = 80 + i * 4;
*cluster = u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]);
}
let mut bad_erase_block_list = [0u32; PS2MC_MAX_INDIRECT_FAT_CLUSTERS];
for (i, block) in bad_erase_block_list.iter_mut().enumerate() {
let offset = 208 + i * 4;
*block = u32::from_le_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]);
}
Ok(Superblock {
magic,
version,
page_size,
pages_per_cluster,
pages_per_erase_block,
clusters_per_card,
allocatable_cluster_offset,
allocatable_cluster_end,
rootdir_fat_cluster,
good_block1,
good_block2,
indirect_fat_cluster_list,
bad_erase_block_list,
})
}
/// Serialize the superblock to raw bytes (512 bytes).
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = vec![0u8; 512];
buf[0..28].copy_from_slice(&self.magic);
buf[28..40].copy_from_slice(&self.version);
buf[40..42].copy_from_slice(&self.page_size.to_le_bytes());
buf[42..44].copy_from_slice(&self.pages_per_cluster.to_le_bytes());
buf[44..46].copy_from_slice(&self.pages_per_erase_block.to_le_bytes());
// padding at 46-47
buf[48..52].copy_from_slice(&self.clusters_per_card.to_le_bytes());
buf[52..56].copy_from_slice(&self.allocatable_cluster_offset.to_le_bytes());
buf[56..60].copy_from_slice(&self.allocatable_cluster_end.to_le_bytes());
buf[60..64].copy_from_slice(&self.rootdir_fat_cluster.to_le_bytes());
buf[64..68].copy_from_slice(&self.good_block1.to_le_bytes());
buf[68..72].copy_from_slice(&self.good_block2.to_le_bytes());
// padding at 72-79
for i in 0..PS2MC_MAX_INDIRECT_FAT_CLUSTERS {
let offset = 80 + i * 4;
buf[offset..offset + 4]
.copy_from_slice(&self.indirect_fat_cluster_list[i].to_le_bytes());
}
for i in 0..PS2MC_MAX_INDIRECT_FAT_CLUSTERS {
let offset = 208 + i * 4;
buf[offset..offset + 4].copy_from_slice(&self.bad_erase_block_list[i].to_le_bytes());
}
buf
}
/// Get the cluster size in bytes.
pub fn cluster_size(&self) -> u32 {
self.page_size as u32 * self.pages_per_cluster as u32
}
/// Get the number of entries per FAT cluster.
pub fn entries_per_cluster(&self) -> u32 {
self.cluster_size() / 4
}
/// Get the spare size (ECC data per page).
pub fn spare_size(&self) -> u32 {
(self.page_size as u32).div_ceil(128) * 4
}
/// Get the raw page size (page + spare).
pub fn raw_page_size(&self) -> u32 {
self.page_size as u32 + self.spare_size()
}
/// Get the allocatable cluster limit.
pub fn allocatable_cluster_limit(&self) -> u32 {
(self.good_block1.min(self.good_block2) * self.pages_per_erase_block as u32)
/ self.pages_per_cluster as u32
- self.allocatable_cluster_offset
}
}

View file

@ -0,0 +1,60 @@
//! Copy-on-write helpers for operations that modify a card image.
use super::card::{ImportFile, MemoryCard};
use anyhow::{anyhow, Context, Result};
use std::fs::{self, File};
use std::path::Path;
use tempfile::NamedTempFile;
pub fn add_directory_atomically(
card_path: impl AsRef<Path>,
name: &str,
directory_mode: u16,
created: [u8; 8],
modified: [u8; 8],
files: &[ImportFile],
) -> Result<()> {
let card_path = fs::canonicalize(card_path.as_ref())
.with_context(|| format!("resolving card path {}", card_path.as_ref().display()))?;
let metadata = fs::metadata(&card_path)?;
let parent = card_path.parent().unwrap_or_else(|| Path::new("."));
let temporary = NamedTempFile::new_in(parent)
.with_context(|| format!("creating temporary image in {}", parent.display()))?;
fs::copy(&card_path, temporary.path())?;
fs::set_permissions(temporary.path(), metadata.permissions())?;
{
let mut card = MemoryCard::open(temporary.path(), true)?;
card.add_directory(name, directory_mode, created, modified, files)?;
}
File::open(temporary.path())?.sync_all()?;
// Validate the staged card after closing the writer, before replacing the
// original image.
let mut staged = MemoryCard::open(temporary.path(), false)?;
let save_entry = staged
.entries()?
.into_iter()
.find(|entry| entry.name == name)
.ok_or_else(|| anyhow!("staged memory card is missing imported save {name}"))?;
let imported_entries = staged.directory_entries(save_entry.first_cluster, save_entry.length)?;
for file in files {
let imported = imported_entries
.iter()
.find(|entry| entry.name == file.name)
.ok_or_else(|| anyhow!("staged save is missing file {}", file.name))?;
if staged.read_file(imported)? != file.data {
return Err(anyhow!("staged file contents differ for {}", file.name));
}
}
drop(staged);
temporary
.persist(&card_path)
.map_err(|error| error.error)
.with_context(|| format!("replacing memory card {}", card_path.display()))?;
if let Ok(directory) = File::open(parent) {
let _ = directory.sync_all();
}
Ok(())
}