commit d4d17ee69d6ae337971b837fa49f397c2907eeb3 Author: markmental Date: Wed Oct 7 13:13:52 2026 -0400 Initial Commit, supports editing PS2 mem cards and adding codebreaker saves diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1deca96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +*.ps2 +*.cbs +mymcplusplus-reference/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4c35042 --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d3bafc8 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..6078a35 --- /dev/null +++ b/README.md @@ -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 +japs2me delete +japs2me add +``` + +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. diff --git a/src/codebreaker/format.rs b/src/codebreaker/format.rs new file mode 100644 index 0000000..47089d9 --- /dev/null +++ b/src/codebreaker/format.rs @@ -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, + 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, +} + +fn crypt(mut data: Vec) -> Vec { + 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(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 { + 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::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 { + 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()); + } +} diff --git a/src/codebreaker/mod.rs b/src/codebreaker/mod.rs new file mode 100644 index 0000000..db7b59d --- /dev/null +++ b/src/codebreaker/mod.rs @@ -0,0 +1 @@ +pub mod format; diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1 @@ + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..73ab22a --- /dev/null +++ b/src/lib.rs @@ -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}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..10a94b5 --- /dev/null +++ b/src/main.rs @@ -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::>(); + add_directory_atomically( + &card, + &save.directory, + save.directory_mode, + save.created, + save.modified, + &files, + )?; + } + } + Ok(()) +} diff --git a/src/memory_card/card.rs b/src/memory_card/card.rs new file mode 100644 index 0000000..9aaea1d --- /dev/null +++ b/src/memory_card/card.rs @@ -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, + 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, write: bool) -> Result { + 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> { + 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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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> { + 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> { + 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 { + 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) -> 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::>(); + 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(()) + } +} diff --git a/src/memory_card/directory.rs b/src/memory_card/directory.rs new file mode 100644 index 0000000..e8468ac --- /dev/null +++ b/src/memory_card/directory.rs @@ -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 { + 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) + } +} diff --git a/src/memory_card/ecc.rs b/src/memory_card/ecc.rs new file mode 100644 index 0000000..1546dd6 --- /dev/null +++ b/src/memory_card/ecc.rs @@ -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 { + 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] + ); + } +} diff --git a/src/memory_card/fat.rs b/src/memory_card/fat.rs new file mode 100644 index 0000000..5bd1083 --- /dev/null +++ b/src/memory_card/fat.rs @@ -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 { + if value == CHAIN_END { + None + } else if value & ALLOCATED != 0 { + Some(value & !ALLOCATED) + } else { + None + } +} diff --git a/src/memory_card/mod.rs b/src/memory_card/mod.rs new file mode 100644 index 0000000..f0e7533 --- /dev/null +++ b/src/memory_card/mod.rs @@ -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; diff --git a/src/memory_card/superblock.rs b/src/memory_card/superblock.rs new file mode 100644 index 0000000..63c1800 --- /dev/null +++ b/src/memory_card/superblock.rs @@ -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 { + 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 { + 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 + } +} diff --git a/src/memory_card/transaction.rs b/src/memory_card/transaction.rs new file mode 100644 index 0000000..857de40 --- /dev/null +++ b/src/memory_card/transaction.rs @@ -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, + 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(()) +}