Initial Commit, supports editing PS2 mem cards and adding codebreaker saves
This commit is contained in:
commit
d4d17ee69d
16 changed files with 1941 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/target
|
||||
*.ps2
|
||||
*.cbs
|
||||
mymcplusplus-reference/
|
||||
402
Cargo.lock
generated
Normal file
402
Cargo.lock
generated
Normal 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
22
Cargo.toml
Normal 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
57
README.md
Normal 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
227
src/codebreaker/format.rs
Normal 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
1
src/codebreaker/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod format;
|
||||
1
src/commands/mod.rs
Normal file
1
src/commands/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
11
src/lib.rs
Normal file
11
src/lib.rs
Normal 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
57
src/main.rs
Normal 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
766
src/memory_card/card.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
75
src/memory_card/directory.rs
Normal file
75
src/memory_card/directory.rs
Normal 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
51
src/memory_card/ecc.rs
Normal 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
15
src/memory_card/fat.rs
Normal 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
8
src/memory_card/mod.rs
Normal 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;
|
||||
184
src/memory_card/superblock.rs
Normal file
184
src/memory_card/superblock.rs
Normal 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
|
||||
}
|
||||
}
|
||||
60
src/memory_card/transaction.rs
Normal file
60
src/memory_card/transaction.rs
Normal 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(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue