mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b1b4d5b5 | ||
|
|
85b38de4a7 | ||
|
|
686bf91bb5 | ||
|
|
f07f8210c3 | ||
|
|
4153d23652 | ||
|
|
760bab0893 | ||
|
|
ce8ddb48bb | ||
|
|
377cbe0aec | ||
|
|
a4c10887cf | ||
|
|
3fcab4d8d9 | ||
|
|
f46d9706eb | ||
|
|
9ecbf0ada5 | ||
|
|
82746ed592 | ||
|
|
da0eeae6a7 | ||
|
|
262f9a7f8d | ||
|
|
ca732d57e7 | ||
|
|
f357e0fe26 | ||
|
|
976aeb4848 | ||
|
|
2bea69c7d9 | ||
|
|
0bb815ca22 | ||
|
|
aa188751e4 | ||
|
|
ba44f7d928 | ||
|
|
8a95787426 | ||
|
|
aebe6a256b | ||
|
|
791b832f25 | ||
|
|
580ce2277d | ||
|
|
8a556c5c63 | ||
|
|
520a3f912c | ||
|
|
6f0eea48fa | ||
|
|
b01010f2d1 | ||
|
|
fd443e3a2f | ||
|
|
8d85a9d778 |
@@ -0,0 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
### Labels — complete rewrite
|
||||
|
||||
- **Detect-then-parse architecture**: each BD-J authoring format has its own parser module with `detect()` and `parse()` functions. Drop in a new parser with one line in the registry.
|
||||
- **5 format parsers**: Paramount (`playlists.xml`), Criterion (`streamproperties.xml`), Pixelogic (`bluray_project.bin`), Warner CTRM (`menu_base.prop` / `language_streams.txt`), shared label vocabulary (`vocab.rs`)
|
||||
- **Raw disc data principle**: label data passes through as-is from disc. Only BD-standard codec identifiers (MLP, AC3, DTS) are mapped to display names. Unknown authoring tool codes (csp, eda, cf) pass through raw.
|
||||
- **`variant` field**: replaces `region` — language dialect codes from authoring tools, not BD spec regions
|
||||
- Removed: old `jar` module (superseded by labels)
|
||||
- Removed: dead label apply functions from disc.rs
|
||||
|
||||
### Drive
|
||||
|
||||
- **`DriveSession::eject()`**: sends PREVENT ALLOW MEDIUM REMOVAL then START STOP UNIT. Works reliably after raw mode unlock.
|
||||
- **`DiscRegion` enum**: Free, BluRay(A/B/C), Dvd(1-8). UHD always region-free.
|
||||
|
||||
### Capture
|
||||
|
||||
- **Fixed sector range collection**: captures ALL files on disc (only skips STREAM/ video files and >50MB). Previously skipped BACKUP/, DUPLICATE/, and files >10MB which missed JAR content.
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Labels module: 4 disc file parsers for stream labels
|
||||
- Simplified labels API
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Initial public release
|
||||
- SCSI transport (Linux SG_IO, macOS IOKit)
|
||||
- UDF 2.50 filesystem reader
|
||||
- MPLS/CLPI parsers with full STN support
|
||||
- Drive identification + profile matching
|
||||
- 206 bundled drive profiles
|
||||
- AACS 1.0 decryption (VUK + unit keys)
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -12,6 +12,7 @@ categories = ["hardware-support", "multimedia"]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
flate2 = "1"
|
||||
@@ -21,6 +22,7 @@ num-integer = "0.1"
|
||||
rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
|
||||
@@ -14,7 +16,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.3"
|
||||
libfreemkv = "0.4"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -42,9 +44,11 @@ while let Some(unit) = reader.read_unit()? {
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock for raw reads
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
|
||||
- **Drive access** — open, identify, unlock, eject
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0, 2.0 in progress)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — sector reads with automatic decryption
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
@@ -61,8 +65,9 @@ Disc — scan titles, streams, AACS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
├── MPLS parser — playlists → titles + clips + streams
|
||||
├── CLPI parser — clip info → EP map → sector extents
|
||||
├── JAR parser — BD-J audio track labels
|
||||
└── AACS — key resolution + content decryption
|
||||
├── Labels — 5 BD-J format parsers (detect + parse)
|
||||
├── AACS — key resolution + content decryption
|
||||
└── KEYDB — download + verify + save
|
||||
```
|
||||
|
||||
See [docs/](docs/) for detailed technical documentation on each module.
|
||||
@@ -80,6 +85,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
| E5xxx | I/O errors |
|
||||
| E6xxx | Disc format errors |
|
||||
| E7xxx | AACS errors |
|
||||
| E8xxx | KEYDB update errors |
|
||||
|
||||
## Platform Support
|
||||
|
||||
|
||||
+3710
-1445
File diff suppressed because it is too large
Load Diff
+428
-14
@@ -67,7 +67,54 @@ const EC_GY: [u8; 20] = [
|
||||
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
|
||||
];
|
||||
|
||||
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
|
||||
// ── AACS 2.0 elliptic curve parameters (P-256 / secp256r1 / NIST prime256v1)
|
||||
|
||||
const P256_P: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
];
|
||||
const P256_A: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC,
|
||||
];
|
||||
#[cfg(test)]
|
||||
const P256_B: [u8; 32] = [
|
||||
0x5A, 0xC6, 0x35, 0xD8, 0xAA, 0x3A, 0x93, 0xE7, 0xB3, 0xEB, 0xBD, 0x55,
|
||||
0x76, 0x98, 0x86, 0xBC, 0x65, 0x1D, 0x06, 0xB0, 0xCC, 0x53, 0xB0, 0xF6,
|
||||
0x3B, 0xCE, 0x3C, 0x3E, 0x27, 0xD2, 0x60, 0x4B,
|
||||
];
|
||||
const P256_N: [u8; 32] = [
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xBC, 0xE6, 0xFA, 0xAD, 0xA7, 0x17, 0x9E, 0x84,
|
||||
0xF3, 0xB9, 0xCA, 0xC2, 0xFC, 0x63, 0x25, 0x51,
|
||||
];
|
||||
const P256_GX: [u8; 32] = [
|
||||
0x6B, 0x17, 0xD1, 0xF2, 0xE1, 0x2C, 0x42, 0x47, 0xF8, 0xBC, 0xE6, 0xE5,
|
||||
0x63, 0xA4, 0x40, 0xF2, 0x77, 0x03, 0x7D, 0x81, 0x2D, 0xEB, 0x33, 0xA0,
|
||||
0xF4, 0xA1, 0x39, 0x45, 0xD8, 0x98, 0xC2, 0x96,
|
||||
];
|
||||
const P256_GY: [u8; 32] = [
|
||||
0x4F, 0xE3, 0x42, 0xE2, 0xFE, 0x1A, 0x7F, 0x9B, 0x8E, 0xE7, 0xEB, 0x4A,
|
||||
0x7C, 0x0F, 0x9E, 0x16, 0x2B, 0xCE, 0x33, 0x57, 0x6B, 0x31, 0x5E, 0xCE,
|
||||
0xCB, 0xB6, 0x40, 0x68, 0x37, 0xBF, 0x51, 0xF5,
|
||||
];
|
||||
|
||||
/// AACS 2.0 LA public key for cert verification (P-256).
|
||||
/// From AACS2 specification — used to verify type 0x11 drive certificates.
|
||||
const AACS2_LA_PUB_X: [u8; 32] = [
|
||||
0xF9, 0x57, 0xBC, 0x1F, 0xD7, 0xE6, 0x09, 0x7E, 0xCA, 0xCC, 0x35, 0x23,
|
||||
0x4C, 0x9C, 0x66, 0xC3, 0x42, 0xEB, 0x3D, 0xB7, 0x2B, 0x41, 0x06, 0xF4,
|
||||
0x04, 0x9C, 0x6A, 0x88, 0x70, 0x00, 0xAA, 0x2C,
|
||||
];
|
||||
const AACS2_LA_PUB_Y: [u8; 32] = [
|
||||
0x39, 0x55, 0x0B, 0x41, 0x02, 0x27, 0xEA, 0x7B, 0x1A, 0x53, 0xF8, 0x67,
|
||||
0x8C, 0x5A, 0x91, 0x6F, 0xFC, 0x7C, 0x78, 0x01, 0x3E, 0x89, 0x15, 0xE3,
|
||||
0xF0, 0x81, 0xD3, 0xE9, 0x3E, 0x17, 0x55, 0x0B,
|
||||
];
|
||||
|
||||
// ── AACS 1.0 LA (Licensing Administrator) public key for cert verification ──
|
||||
|
||||
const AACS_LA_PUB_X: [u8; 20] = [
|
||||
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
|
||||
@@ -350,6 +397,132 @@ fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
// ── P-256 ECDSA (SHA-256) for AACS 2.0 ─────────────────────────────────────
|
||||
|
||||
/// ECDSA sign with P-256/SHA-256. Returns (r, s) each 32 bytes.
|
||||
fn ecdsa_sign_p256(priv_key: &[u8; 32], data: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
use sha2::{Sha256, Digest as Sha2Digest};
|
||||
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
let d = BigUint::from_bytes_be(priv_key);
|
||||
|
||||
let hash = Sha256::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
loop {
|
||||
let mut k_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut k_bytes);
|
||||
let k = BigUint::from_bytes_be(&k_bytes) % &n;
|
||||
if k.is_zero() { continue; }
|
||||
|
||||
let r_point = ec_mul(&k, &g, &a, &p);
|
||||
let r = &r_point.x % &n;
|
||||
if r.is_zero() { continue; }
|
||||
|
||||
let k_inv = match mod_inv(&k, &n) {
|
||||
Some(v) => v,
|
||||
None => continue,
|
||||
};
|
||||
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
|
||||
if s.is_zero() { continue; }
|
||||
|
||||
let r_bytes = to_bytes_be_padded(&r, 32);
|
||||
let s_bytes = to_bytes_be_padded(&s, 32);
|
||||
|
||||
let mut r_out = [0u8; 32];
|
||||
let mut s_out = [0u8; 32];
|
||||
r_out.copy_from_slice(&r_bytes);
|
||||
s_out.copy_from_slice(&s_bytes);
|
||||
|
||||
return (r_out, s_out);
|
||||
}
|
||||
}
|
||||
|
||||
/// ECDSA verify with P-256/SHA-256.
|
||||
fn ecdsa_verify_p256(pub_x: &[u8], pub_y: &[u8], sig_r: &[u8], sig_s: &[u8], data: &[u8]) -> bool {
|
||||
use sha2::{Sha256, Digest as Sha2Digest};
|
||||
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
let q = EcPoint::new(BigUint::from_bytes_be(pub_x), BigUint::from_bytes_be(pub_y));
|
||||
|
||||
let r = BigUint::from_bytes_be(sig_r);
|
||||
let s = BigUint::from_bytes_be(sig_s);
|
||||
|
||||
if r.is_zero() || r >= n || s.is_zero() || s >= n {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hash = Sha256::digest(data);
|
||||
let z = BigUint::from_bytes_be(&hash);
|
||||
|
||||
let s_inv = match mod_inv(&s, &n) {
|
||||
Some(v) => v,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let u1 = (&z * &s_inv) % &n;
|
||||
let u2 = (&r * &s_inv) % &n;
|
||||
|
||||
let p1 = ec_mul(&u1, &g, &a, &p);
|
||||
let p2 = ec_mul(&u2, &q, &a, &p);
|
||||
let r_point = ec_add(&p1, &p2, &a, &p);
|
||||
|
||||
if r_point.infinity {
|
||||
return false;
|
||||
}
|
||||
|
||||
&r_point.x % &n == r
|
||||
}
|
||||
|
||||
/// Verify an AACS 2.0 certificate (type 0x11, 132 bytes) against AACS 2.0 LA key.
|
||||
fn verify_cert_p256(cert: &[u8]) -> bool {
|
||||
if cert.len() < 132 { return false; }
|
||||
// AACS 2.0 cert: type(1) + flags(1) + padding(2) + serial(6) + pub_x(32) + pub_y(32) + sig_r(32) + sig_s(32)
|
||||
// Signature is over the first 74 bytes
|
||||
let sig_r = &cert[74..106];
|
||||
let sig_s = &cert[106..138]; // some certs may be padded differently
|
||||
|
||||
// Use what we have — verify over the signed portion
|
||||
if cert.len() >= 138 {
|
||||
ecdsa_verify_p256(&AACS2_LA_PUB_X, &AACS2_LA_PUB_Y, sig_r, sig_s, &cert[..74])
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract public key from an AACS 2.0 certificate (32-byte x,y).
|
||||
fn cert_pub_key_p256(cert: &[u8]) -> ([u8; 32], [u8; 32]) {
|
||||
let mut x = [0u8; 32];
|
||||
let mut y = [0u8; 32];
|
||||
x.copy_from_slice(&cert[10..42]);
|
||||
y.copy_from_slice(&cert[42..74]);
|
||||
(x, y)
|
||||
}
|
||||
|
||||
/// Compute bus key via ECDH on P-256 curve.
|
||||
fn compute_bus_key_p256(host_priv: &[u8; 32], drive_key_point_x: &[u8], drive_key_point_y: &[u8]) -> [u8; 16] {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
|
||||
let d = BigUint::from_bytes_be(host_priv);
|
||||
let dkp = EcPoint::new(BigUint::from_bytes_be(drive_key_point_x), BigUint::from_bytes_be(drive_key_point_y));
|
||||
|
||||
let shared = ec_mul(&d, &dkp, &a, &p);
|
||||
|
||||
// Bus key = lowest 128 bits of x-coordinate
|
||||
let x_bytes = to_bytes_be_padded(&shared.x, 32);
|
||||
let mut bus_key = [0u8; 16];
|
||||
bus_key.copy_from_slice(&x_bytes[16..32]);
|
||||
bus_key
|
||||
}
|
||||
|
||||
// ── AACS certificate handling ───────────────────────────────────────────────
|
||||
|
||||
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
|
||||
@@ -394,6 +567,31 @@ fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key
|
||||
}
|
||||
|
||||
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
|
||||
/// Generate P-256 ephemeral key pair for AACS 2.0.
|
||||
fn generate_host_key_pair_p256() -> ([u8; 32], [u8; 32], [u8; 32]) {
|
||||
let p_mod = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let mut priv_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
|
||||
|
||||
let q = ec_mul(&d, &g, &a, &p_mod);
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
let mut pub_x = [0u8; 32];
|
||||
let mut pub_y = [0u8; 32];
|
||||
key.copy_from_slice(&to_bytes_be_padded(&d, 32));
|
||||
pub_x.copy_from_slice(&to_bytes_be_padded(&q.x, 32));
|
||||
pub_y.copy_from_slice(&to_bytes_be_padded(&q.y, 32));
|
||||
|
||||
(key, pub_x, pub_y)
|
||||
}
|
||||
|
||||
/// Generate AACS 1.0 ephemeral key pair.
|
||||
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
let p_mod = BigUint::from_bytes_be(&EC_P);
|
||||
let a = BigUint::from_bytes_be(&EC_A);
|
||||
@@ -558,21 +756,18 @@ pub fn aacs_authenticate(
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
drive_cert.copy_from_slice(&response[24..116]);
|
||||
|
||||
// Detect AACS 2.0 drive certificate (type 0x11)
|
||||
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
|
||||
// for backward compatibility. We proceed with AACS 1.0 handshake.
|
||||
if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
|
||||
// (full P-256 AACS 2.0 handshake not yet implemented)
|
||||
// The drive should still accept our AACS 1.0 host certificate.
|
||||
// Verify drive certificate
|
||||
if drive_cert[0] == 0x01 {
|
||||
// AACS 1.0 certificate
|
||||
if !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
||||
}
|
||||
} else if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 certificate — verify with P-256 LA key
|
||||
// Note: AACS 2.0 drives still accept AACS 1.0 host certs for compatibility
|
||||
// Verification is optional here since we proceed with AACS 1.0 flow anyway
|
||||
}
|
||||
|
||||
// Verify drive certificate (AACS 1.0 LA signature)
|
||||
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
||||
}
|
||||
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
|
||||
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
let cdb = cdb_report_key(agid, 0x02, 84);
|
||||
let response = scsi_read(session, &cdb, 84)
|
||||
@@ -635,6 +830,149 @@ pub fn aacs_authenticate(
|
||||
})
|
||||
}
|
||||
|
||||
/// Full AACS 2.0 authentication using P-256/SHA-256.
|
||||
///
|
||||
/// Used when both host and drive support AACS 2.0 natively.
|
||||
/// Falls back to aacs_authenticate (AACS 1.0) if AACS 2.0 host credentials
|
||||
/// are not available.
|
||||
pub fn aacs2_authenticate(
|
||||
session: &mut DriveSession,
|
||||
host_priv_key_v1: &[u8; 20],
|
||||
host_cert_v1: &[u8],
|
||||
host_priv_key_v2: Option<&[u8; 32]>,
|
||||
host_cert_v2: Option<&[u8]>,
|
||||
) -> Result<AacsAuth> {
|
||||
// Try AACS 1.0 first (backward compatible with all drives)
|
||||
match aacs_authenticate(session, host_priv_key_v1, host_cert_v1) {
|
||||
Ok(auth) => return Ok(auth),
|
||||
Err(_) => {
|
||||
// AACS 1.0 rejected — try native P-256 if we have v2 credentials
|
||||
}
|
||||
}
|
||||
|
||||
// AACS 2.0 native P-256 handshake
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or_else(|| Error::AacsError {
|
||||
detail: "AACS 2.0 host credentials required but not available".into(),
|
||||
})?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or_else(|| Error::AacsError {
|
||||
detail: "AACS 2.0 host certificate required but not available".into(),
|
||||
})?;
|
||||
|
||||
aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2)
|
||||
}
|
||||
|
||||
/// Native AACS 2.0 handshake using P-256/SHA-256.
|
||||
/// Same SCSI protocol, larger payloads (32-byte keys, 132-byte certs).
|
||||
fn aacs2_authenticate_p256(
|
||||
session: &mut DriveSession,
|
||||
host_priv_key: &[u8; 32],
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 132 {
|
||||
return Err(Error::AacsError { detail: "AACS 2.0 host cert too short".into() });
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
for agid in 0..4u8 {
|
||||
let cdb = cdb_report_key(agid, 0x3F, 2);
|
||||
let _ = scsi_read(session, &cdb, 2);
|
||||
}
|
||||
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|_| Error::AacsError { detail: "AGID allocation failed".into() })?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce + P-256 ephemeral key pair
|
||||
let mut host_nonce = [0u8; 20];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_nonce);
|
||||
let (host_eph_key, host_eph_pub_x, host_eph_pub_y) = generate_host_key_pair_p256();
|
||||
|
||||
// Step 4: Send AACS 2.0 host certificate + nonce
|
||||
// AACS 2.0: cert is 132 bytes, total payload = 4 + 20 + 132 = 156
|
||||
let mut send_buf = vec![0u8; 156];
|
||||
send_buf[1] = 0x9a; // data length (154)
|
||||
send_buf[4..24].copy_from_slice(&host_nonce);
|
||||
send_buf[24..156].copy_from_slice(&host_cert[..132]);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 156);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host cert".into() })?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce
|
||||
// AACS 2.0 drive cert is also 132 bytes
|
||||
let cdb = cdb_report_key(agid, 0x01, 156);
|
||||
let response = scsi_read(session, &cdb, 156)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive cert".into() })?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
let drive_cert = &response[24..156];
|
||||
|
||||
// Verify drive certificate with AACS 2.0 LA key
|
||||
if drive_cert[0] == 0x11 && !verify_cert_p256(drive_cert) {
|
||||
// Non-fatal: some cert formats may differ
|
||||
}
|
||||
|
||||
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
|
||||
let cdb = cdb_report_key(agid, 0x02, 132);
|
||||
let response = scsi_read(session, &cdb, 132)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive key".into() })?;
|
||||
|
||||
let drive_key_x = &response[4..36];
|
||||
let drive_key_y = &response[36..68];
|
||||
let drive_sig_r = &response[68..100];
|
||||
let drive_sig_s = &response[100..132];
|
||||
|
||||
// Verify drive key signature
|
||||
let (drive_pub_x, drive_pub_y) = cert_pub_key_p256(drive_cert);
|
||||
let mut verify_data = Vec::with_capacity(84);
|
||||
verify_data.extend_from_slice(&host_nonce);
|
||||
verify_data.extend_from_slice(drive_key_x);
|
||||
verify_data.extend_from_slice(drive_key_y);
|
||||
|
||||
if !ecdsa_verify_p256(&drive_pub_x, &drive_pub_y, drive_sig_r, drive_sig_s, &verify_data) {
|
||||
return Err(Error::AacsError { detail: "AACS 2.0 drive key verification failed".into() });
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point
|
||||
let mut sign_data = Vec::with_capacity(84);
|
||||
sign_data.extend_from_slice(&drive_nonce);
|
||||
sign_data.extend_from_slice(&host_eph_pub_x);
|
||||
sign_data.extend_from_slice(&host_eph_pub_y);
|
||||
|
||||
let (host_sig_r, host_sig_s) = ecdsa_sign_p256(host_priv_key, &sign_data);
|
||||
|
||||
// Step 8: Send host key point + signature (P-256: 64+64 = 128 bytes payload)
|
||||
let mut send_buf = vec![0u8; 132];
|
||||
send_buf[1] = 0x82; // data length
|
||||
send_buf[4..36].copy_from_slice(&host_eph_pub_x);
|
||||
send_buf[36..68].copy_from_slice(&host_eph_pub_y);
|
||||
send_buf[68..100].copy_from_slice(&host_sig_r);
|
||||
send_buf[100..132].copy_from_slice(&host_sig_s);
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 132);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host key".into() })?;
|
||||
|
||||
// Step 9: Compute bus key via P-256 ECDH
|
||||
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y);
|
||||
|
||||
Ok(AacsAuth {
|
||||
bus_key,
|
||||
agid,
|
||||
volume_id: None,
|
||||
read_data_key: None,
|
||||
drive_cert: {
|
||||
let mut dc = [0u8; 92];
|
||||
dc.copy_from_slice(&drive_cert[..92.min(drive_cert.len())]);
|
||||
dc
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Read Volume ID after successful authentication.
|
||||
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
@@ -754,6 +1092,82 @@ mod tests {
|
||||
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_generator_on_curve() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let b = BigUint::from_bytes_be(&P256_B);
|
||||
let gx = BigUint::from_bytes_be(&P256_GX);
|
||||
let gy = BigUint::from_bytes_be(&P256_GY);
|
||||
|
||||
let lhs = (&gy * &gy) % &p;
|
||||
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
|
||||
assert_eq!(lhs, rhs, "P-256 generator not on curve");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_mul_order() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let result = ec_mul(&n, &g, &a, &p);
|
||||
assert!(result.infinity, "n × G should be point at infinity on P-256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_ecdsa_sign_verify() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
// Generate random P-256 key pair
|
||||
let mut priv_bytes = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut priv_bytes);
|
||||
let d = BigUint::from_bytes_be(&priv_bytes) % &n;
|
||||
let priv_key: [u8; 32] = to_bytes_be_padded(&d, 32).try_into().unwrap();
|
||||
|
||||
let pub_point = ec_mul(&d, &g, &a, &p);
|
||||
let pub_x: Vec<u8> = to_bytes_be_padded(&pub_point.x, 32);
|
||||
let pub_y: Vec<u8> = to_bytes_be_padded(&pub_point.y, 32);
|
||||
|
||||
let data = b"AACS 2.0 P-256 ECDSA test";
|
||||
let (sig_r, sig_s) = ecdsa_sign_p256(&priv_key, data);
|
||||
assert!(ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, data));
|
||||
assert!(!ecdsa_verify_p256(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_p256_ecdh() {
|
||||
let p = BigUint::from_bytes_be(&P256_P);
|
||||
let a = BigUint::from_bytes_be(&P256_A);
|
||||
let n = BigUint::from_bytes_be(&P256_N);
|
||||
let g = EcPoint::from_bytes(&P256_GX, &P256_GY);
|
||||
|
||||
let mut pa = [0u8; 32];
|
||||
let mut pb = [0u8; 32];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut pa);
|
||||
rand::thread_rng().fill_bytes(&mut pb);
|
||||
let da = BigUint::from_bytes_be(&pa) % &n;
|
||||
let db = BigUint::from_bytes_be(&pb) % &n;
|
||||
let priv_a: [u8; 32] = to_bytes_be_padded(&da, 32).try_into().unwrap();
|
||||
let priv_b: [u8; 32] = to_bytes_be_padded(&db, 32).try_into().unwrap();
|
||||
|
||||
let pub_a = ec_mul(&da, &g, &a, &p);
|
||||
let pub_b = ec_mul(&db, &g, &a, &p);
|
||||
|
||||
let key_a = compute_bus_key_p256(&priv_a,
|
||||
&to_bytes_be_padded(&pub_b.x, 32), &to_bytes_be_padded(&pub_b.y, 32));
|
||||
let key_b = compute_bus_key_p256(&priv_b,
|
||||
&to_bytes_be_padded(&pub_a.x, 32), &to_bytes_be_padded(&pub_a.y, 32));
|
||||
|
||||
assert_eq!(key_a, key_b, "P-256 ECDH shared secrets should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aes_cmac() {
|
||||
// Basic CMAC test — at minimum verify it produces consistent output
|
||||
|
||||
+37
-2
@@ -44,8 +44,14 @@ pub struct DeviceKey {
|
||||
/// Host certificate + private key for AACS SCSI authentication.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0: 20 bytes. AACS 2.0: 32 bytes.
|
||||
pub private_key: [u8; 20],
|
||||
pub certificate: Vec<u8>, // 92 bytes
|
||||
/// AACS 1.0: 92 bytes. AACS 2.0: 132 bytes.
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). None for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). None for AACS 1.0 only.
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A per-disc entry from the key database.
|
||||
@@ -127,7 +133,18 @@ impl KeyDb {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate
|
||||
// Host Certificate (AACS 2.0)
|
||||
if line.starts_with("| HC2") {
|
||||
if let Some(ref mut hc) = db.host_cert {
|
||||
if let Some((pk, cert)) = Self::parse_host_cert_v2(line) {
|
||||
hc.private_key_v2 = Some(pk);
|
||||
hc.certificate_v2 = Some(cert);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host Certificate (AACS 1.0)
|
||||
if line.starts_with("| HC") {
|
||||
db.host_cert = Self::parse_host_cert(line);
|
||||
continue;
|
||||
@@ -201,9 +218,27 @@ impl KeyDb {
|
||||
Some(HostCert {
|
||||
private_key: parse_hex20(priv_str)?,
|
||||
certificate: parse_hex(cert_str)?,
|
||||
private_key_v2: None,
|
||||
certificate_v2: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse AACS 2.0 host cert: `| HC2 | HOST_PRIV_KEY 0x... | HOST_CERT 0x...`
|
||||
fn parse_host_cert_v2(line: &str) -> Option<([u8; 32], Vec<u8>)> {
|
||||
let priv_str = line.split("HOST_PRIV_KEY").nth(1)?.split('|').next()?.trim();
|
||||
let cert_str = line.split("HOST_CERT").nth(1)?.split(';').next()?.split('|').next()?.trim();
|
||||
|
||||
let priv_bytes = parse_hex(priv_str)?;
|
||||
if priv_bytes.len() != 32 { return None; }
|
||||
let mut pk = [0u8; 32];
|
||||
pk.copy_from_slice(&priv_bytes);
|
||||
|
||||
let cert = parse_hex(cert_str)?;
|
||||
if cert.len() < 132 { return None; }
|
||||
|
||||
Some((pk, cert))
|
||||
}
|
||||
|
||||
fn parse_disc_entry(line: &str) -> Option<DiscEntry> {
|
||||
// 0x<hash> = <title> | D | <date> | M | 0x<mk> | I | 0x<id> | V | 0x<vuk> | U | <unit_keys>
|
||||
let (hash_part, rest) = line.split_once(" = ")?;
|
||||
|
||||
+25
-4
@@ -183,11 +183,32 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
|
||||
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
|
||||
// stream_PID: 16 bits → ep_map[2..4]
|
||||
// reserved: 10 bits ┐
|
||||
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
|
||||
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
|
||||
// num_EP_fine: 18 bits │
|
||||
// EP_map_start_address: 32 bits ┘
|
||||
if ep_map.len() < 16 { return Ok((Vec::new(), Vec::new())); }
|
||||
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
|
||||
// ep_map[4..6] = reserved + EP stream type
|
||||
let num_coarse = u16::from_be_bytes([ep_map[6], ep_map[7]]) as usize;
|
||||
let num_fine = u32::from_be_bytes([ep_map[8], ep_map[9], ep_map[10], ep_map[11]]) as usize;
|
||||
let ep_map_offset = u32::from_be_bytes([ep_map[12], ep_map[13], ep_map[14], ep_map[15]]) as usize;
|
||||
|
||||
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
|
||||
// Use two u64s since we need 80 bits
|
||||
let hi = u64::from_be_bytes([ep_map[4], ep_map[5], ep_map[6], ep_map[7],
|
||||
ep_map[8], ep_map[9], ep_map[10], ep_map[11]]);
|
||||
let lo_bytes = [ep_map[12], ep_map[13]];
|
||||
|
||||
// Bit 0-9: reserved (10)
|
||||
// Bit 10-13: EP_stream_type (4)
|
||||
// Bit 14-29: num_coarse (16)
|
||||
// Bit 30-47: num_fine (18)
|
||||
// Bit 48-79: EP_map_start (32) — bits 48-63 in hi, bits 64-79 in lo
|
||||
let num_coarse = ((hi >> 34) & 0xFFFF) as usize;
|
||||
let num_fine = ((hi >> 16) & 0x3FFFF) as usize;
|
||||
let ep_map_offset = (((hi & 0xFFFF) as u32) << 16)
|
||||
| (u16::from_be_bytes(lo_bytes) as u32);
|
||||
let ep_map_offset = ep_map_offset as usize;
|
||||
|
||||
// EP map for this stream starts at ep_map_offset relative to ep_map start
|
||||
if ep_map_offset + 4 > ep_map.len() {
|
||||
|
||||
+564
-194
@@ -14,21 +14,69 @@ use crate::udf;
|
||||
use crate::mpls;
|
||||
use crate::clpi;
|
||||
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A scanned Blu-ray disc.
|
||||
#[derive(Debug)]
|
||||
pub struct Disc {
|
||||
/// UDF Volume Identifier from Primary Volume Descriptor (always present)
|
||||
pub volume_id: String,
|
||||
/// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
|
||||
pub meta_title: Option<String>,
|
||||
/// Disc format (BD, UHD, DVD)
|
||||
pub format: DiscFormat,
|
||||
/// Disc capacity in sectors
|
||||
pub capacity_sectors: u32,
|
||||
/// Disc capacity in bytes
|
||||
pub capacity_bytes: u64,
|
||||
/// Number of layers (1 = single, 2 = dual)
|
||||
pub layers: u8,
|
||||
/// Titles sorted by duration (longest first), then playlist name
|
||||
pub titles: Vec<Title>,
|
||||
/// Disc region
|
||||
pub region: DiscRegion,
|
||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
||||
pub aacs: Option<AacsState>,
|
||||
/// Whether this disc requires AACS decryption
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
/// Disc format.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum DiscFormat {
|
||||
/// 4K UHD Blu-ray (HEVC 2160p)
|
||||
Uhd,
|
||||
/// Standard Blu-ray (1080p/1080i)
|
||||
BluRay,
|
||||
/// DVD
|
||||
Dvd,
|
||||
/// Unknown
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Disc playback region.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DiscRegion {
|
||||
/// Region-free (all UHD discs, some BD/DVD)
|
||||
Free,
|
||||
/// Blu-ray regions (A/B/C or combination)
|
||||
BluRay(Vec<BdRegion>),
|
||||
/// DVD regions (1-8 or combination)
|
||||
Dvd(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Blu-ray region codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
A,
|
||||
/// Region B/2 — Europe, Africa, Australia, Middle East
|
||||
B,
|
||||
/// Region C/3 — Central/South Asia, China, Russia
|
||||
C,
|
||||
}
|
||||
|
||||
/// A title (one MPLS playlist).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Title {
|
||||
@@ -40,49 +88,88 @@ pub struct Title {
|
||||
pub duration_secs: f64,
|
||||
/// Total size in bytes
|
||||
pub size_bytes: u64,
|
||||
/// Number of clips
|
||||
pub clip_count: usize,
|
||||
/// Clip references in playback order
|
||||
pub clips: Vec<Clip>,
|
||||
/// All streams (video, audio, subtitle, etc.)
|
||||
pub streams: Vec<Stream>,
|
||||
/// Sector extents for ripping (clip LBA ranges)
|
||||
pub extents: Vec<Extent>,
|
||||
}
|
||||
|
||||
/// A clip reference within a title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Clip {
|
||||
/// Clip filename without extension (e.g. "00001")
|
||||
pub clip_id: String,
|
||||
/// In-time in 45kHz ticks
|
||||
pub in_time: u32,
|
||||
/// Out-time in 45kHz ticks
|
||||
pub out_time: u32,
|
||||
/// Duration in seconds
|
||||
pub duration_secs: f64,
|
||||
/// Source packet count (from CLPI, 0 if unavailable)
|
||||
pub source_packets: u32,
|
||||
}
|
||||
|
||||
/// A stream within a title.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Stream {
|
||||
/// Stream type
|
||||
pub kind: StreamKind,
|
||||
pub enum Stream {
|
||||
Video(VideoStream),
|
||||
Audio(AudioStream),
|
||||
Subtitle(SubtitleStream),
|
||||
}
|
||||
|
||||
/// A video stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec
|
||||
/// Codec (HEVC, H.264, VC-1, MPEG-2)
|
||||
pub codec: Codec,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Video resolution (e.g. "2160p", "1080p")
|
||||
/// Resolution (e.g. "2160p", "1080p", "1080i")
|
||||
pub resolution: String,
|
||||
/// Frame rate (e.g. "23.976")
|
||||
/// Frame rate (e.g. "23.976", "25")
|
||||
pub frame_rate: String,
|
||||
/// Channel layout (e.g. "5.1", "7.1", "stereo")
|
||||
pub channels: String,
|
||||
/// Sample rate (e.g. "48kHz")
|
||||
pub sample_rate: String,
|
||||
/// HDR format
|
||||
pub hdr: HdrFormat,
|
||||
/// Color space
|
||||
pub color_space: ColorSpace,
|
||||
/// Whether this is a secondary/enhancement stream
|
||||
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
|
||||
pub secondary: bool,
|
||||
/// Extra label (e.g. "Dolby Vision EL")
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Stream type.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum StreamKind {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
/// An audio stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
|
||||
pub codec: Codec,
|
||||
/// Channel layout (e.g. "5.1", "7.1", "stereo", "mono")
|
||||
pub channels: String,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Sample rate (e.g. "48kHz", "96kHz")
|
||||
pub sample_rate: String,
|
||||
/// Whether this is a secondary stream (commentary)
|
||||
pub secondary: bool,
|
||||
/// Extra label
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// A subtitle stream.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SubtitleStream {
|
||||
/// MPEG-TS packet ID
|
||||
pub pid: u16,
|
||||
/// Codec (PGS)
|
||||
pub codec: Codec,
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Whether this is a forced subtitle
|
||||
pub forced: bool,
|
||||
}
|
||||
|
||||
/// Video/audio codec.
|
||||
@@ -210,46 +297,6 @@ impl Title {
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Human-readable one-line description.
|
||||
pub fn display(&self) -> String {
|
||||
match self.kind {
|
||||
StreamKind::Video => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
|
||||
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
|
||||
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
|
||||
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
|
||||
parts.push(self.color_space.name().to_string());
|
||||
}
|
||||
if self.secondary { parts.push(format!("[{}]", self.label)); }
|
||||
parts.join(" ")
|
||||
}
|
||||
StreamKind::Audio => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
|
||||
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
|
||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||
if self.secondary { parts.push("[secondary]".to_string()); }
|
||||
parts.join(" ")
|
||||
}
|
||||
StreamKind::Subtitle => {
|
||||
let mut parts = vec![self.codec.name().to_string()];
|
||||
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
|
||||
parts.join(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind as a display string
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
match self.kind {
|
||||
StreamKind::Video => "Video",
|
||||
StreamKind::Audio => "Audio",
|
||||
StreamKind::Subtitle => "Subtitle",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AACS state ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -368,14 +415,39 @@ impl Disc {
|
||||
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
/// }
|
||||
/// ```
|
||||
/// Scan a disc. One pipeline, one order:
|
||||
/// 1. Read capacity
|
||||
/// 2. Read UDF filesystem
|
||||
/// 3. Resolve AACS keys (all via UDF, no SCSI commands)
|
||||
/// 4. Parse playlists + streams
|
||||
/// 5. Apply labels
|
||||
///
|
||||
/// The session must be open and unlocked (DriveSession::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
// Step 1: Read capacity
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
// 1. Capacity
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
|
||||
// Step 2: Parse UDF filesystem
|
||||
// 2. UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
|
||||
// Step 3: Find and parse MPLS playlists
|
||||
// 3. AACS — read files from disc via UDF, resolve keys via KEYDB
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
Self::resolve_aacs(&udf_fs, session, &keydb_path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 4. Playlists
|
||||
let mut titles = Vec::new();
|
||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
||||
for entry in &playlist_dir.entries {
|
||||
@@ -389,129 +461,149 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: longest first
|
||||
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
// Step 4: Detect AACS encryption
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
// 5. Metadata + labels
|
||||
let meta_title = Self::read_meta_title(session, &udf_fs);
|
||||
crate::labels::apply(session, &udf_fs, &mut titles);
|
||||
|
||||
// Step 5: If encrypted and KEYDB available, authenticate and derive keys
|
||||
let aacs = if encrypted {
|
||||
if let Some(keydb_path) = opts.resolve_keydb() {
|
||||
match Self::setup_aacs(session, &keydb_path) {
|
||||
Ok(state) => Some(state),
|
||||
Err(_) => None, // keys not found, continue without decryption
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 6. Derive format, layers, region
|
||||
let format = Self::detect_format(&titles);
|
||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||
let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free };
|
||||
|
||||
Ok(Disc {
|
||||
volume_id: udf_fs.volume_id.clone(),
|
||||
meta_title,
|
||||
format,
|
||||
capacity_sectors: capacity,
|
||||
capacity_bytes: capacity as u64 * 2048,
|
||||
layers,
|
||||
titles,
|
||||
region,
|
||||
aacs,
|
||||
encrypted,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set up AACS decryption for this disc.
|
||||
/// Call after scan() to enable transparent content decryption.
|
||||
pub fn setup_aacs(
|
||||
/// Resolve AACS keys from disc files + KEYDB. No SCSI commands.
|
||||
/// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF.
|
||||
fn resolve_aacs(
|
||||
udf_fs: &udf::UdfFs,
|
||||
session: &mut DriveSession,
|
||||
keydb_path: &std::path::Path,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::aacs::handshake;
|
||||
|
||||
// Load KEYDB
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
|
||||
detail: format!("failed to load KEYDB: {}", e),
|
||||
})?;
|
||||
|
||||
// Step 1: Try SCSI handshake for Volume ID + read_data_key
|
||||
// Open a separate transport (AACS auth must happen before raw mode).
|
||||
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
|
||||
// fall back to disc-hash-only KEYDB lookup.
|
||||
let device_path = session.device_path().to_string();
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut read_data_key: Option<[u8; 16]> = None;
|
||||
|
||||
if !device_path.is_empty() {
|
||||
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
|
||||
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
|
||||
if let Ok(mut auth) = handshake::aacs_authenticate(
|
||||
&mut aacs_session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
|
||||
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
|
||||
.ok().map(|(rdk, _)| rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handshake failure is not fatal — we can still resolve via disc hash
|
||||
}
|
||||
|
||||
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
// Read AACS files from disc via UDF (standard READ(10), no vendor commands)
|
||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsError {
|
||||
detail: "failed to read Unit_Key_RO.inf from disc".into(),
|
||||
detail: "Unit_Key_RO.inf not found on disc".into(),
|
||||
})?;
|
||||
|
||||
// Step 3: Read Content Certificate (optional — for AACS version detection)
|
||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
// Step 4: Resolve keys
|
||||
// If we have VID from handshake, use full 4-path chain.
|
||||
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
|
||||
let mkb_data = aacs::read_mkb_from_drive(session).ok();
|
||||
let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// Use a zero VID placeholder if handshake failed — resolve_keys
|
||||
// will still work via disc hash (path 1)
|
||||
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
|
||||
|
||||
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
|
||||
let vid_zero = [0u8; 16];
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
&vid_for_resolve,
|
||||
&vid_zero,
|
||||
&keydb,
|
||||
mkb_data.as_deref(),
|
||||
).ok_or_else(|| Error::AacsError {
|
||||
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
|
||||
detail: "disc not in KEYDB".into(),
|
||||
})?;
|
||||
|
||||
let key_source = match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
_ => KeySource::KeyDb,
|
||||
};
|
||||
|
||||
Ok(AacsState {
|
||||
version: if resolved.aacs2 { 2 } else { 1 },
|
||||
bus_encryption: resolved.bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source,
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::KeyDb,
|
||||
2 => KeySource::KeyDbDerived,
|
||||
3 => KeySource::ProcessingKey,
|
||||
4 => KeySource::DeviceKey,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key,
|
||||
volume_id: vid.unwrap_or([0u8; 16]),
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────
|
||||
|
||||
/// Detect disc format from the main title's video streams.
|
||||
fn detect_format(titles: &[Title]) -> DiscFormat {
|
||||
for title in titles.iter().take(3) {
|
||||
for stream in &title.streams {
|
||||
if let Stream::Video(v) = stream {
|
||||
if v.resolution.contains("2160") {
|
||||
return DiscFormat::Uhd;
|
||||
}
|
||||
if v.resolution.contains("1080") || v.resolution.contains("720") {
|
||||
return DiscFormat::BluRay;
|
||||
}
|
||||
if v.resolution.contains("480") || v.resolution.contains("576") {
|
||||
return DiscFormat::Dvd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DiscFormat::Unknown
|
||||
}
|
||||
|
||||
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
for sub in &meta_dir.entries {
|
||||
if !sub.is_dir { continue; }
|
||||
let dl_path = format!("/BDMV/META/{}", sub.name);
|
||||
if let Some(dl_dir) = udf_fs.find_dir(&dl_path) {
|
||||
let xml_files: Vec<_> = dl_dir.entries.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml"))
|
||||
.collect();
|
||||
|
||||
let eng = xml_files.iter().find(|e| e.name.to_lowercase().contains("eng"));
|
||||
let target = eng.or_else(|| xml_files.first());
|
||||
|
||||
if let Some(entry) = target {
|
||||
let path = format!("{}/{}", dl_path, entry.name);
|
||||
if let Ok(data) = udf_fs.read_file(session, &path) {
|
||||
let xml = String::from_utf8_lossy(&data);
|
||||
if let Some(start) = xml.find("<di:name>") {
|
||||
let s = start + "<di:name>".len();
|
||||
if let Some(end) = xml[s..].find("</di:name>") {
|
||||
let title = xml[s..s + end].trim().to_string();
|
||||
if !title.is_empty() && title != "Blu-ray" {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
@@ -539,47 +631,95 @@ impl Disc {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse each clip for EP map → sector extents
|
||||
// Parse each clip for size, duration, and sector extents
|
||||
let mut extents = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
let clip_count = parsed.play_items.len();
|
||||
let mut clips = Vec::with_capacity(parsed.play_items.len());
|
||||
|
||||
for play_item in &parsed.play_items {
|
||||
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
|
||||
let mut pkt_count: u32 = 0;
|
||||
|
||||
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
|
||||
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
|
||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
||||
// Use EP map to get sector extents for this clip's time range
|
||||
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
|
||||
for ext in &clip_extents {
|
||||
total_size += ext.sector_count as u64 * 2048;
|
||||
pkt_count = clip_info.source_packet_count;
|
||||
total_size += pkt_count as u64 * 192;
|
||||
|
||||
// Get m2ts file start LBA and compute extent from packet count.
|
||||
// BD-ROM m2ts files are contiguous on disc (mastering requirement).
|
||||
let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id);
|
||||
let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0);
|
||||
let total_bytes = pkt_count as u64 * 192;
|
||||
let total_sectors = ((total_bytes + 2047) / 2048) as u32;
|
||||
if total_sectors > 0 && file_lba > 0 {
|
||||
extents.push(Extent {
|
||||
start_lba: file_lba,
|
||||
sector_count: total_sectors,
|
||||
});
|
||||
}
|
||||
extents.extend(clip_extents);
|
||||
}
|
||||
}
|
||||
|
||||
clips.push(Clip {
|
||||
clip_id: play_item.clip_id.clone(),
|
||||
in_time: play_item.in_time,
|
||||
out_time: play_item.out_time,
|
||||
duration_secs: clip_dur,
|
||||
source_packets: pkt_count,
|
||||
});
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
|
||||
let kind = match s.stream_type {
|
||||
1 => StreamKind::Video,
|
||||
2 => StreamKind::Audio,
|
||||
3 => StreamKind::Subtitle,
|
||||
_ => StreamKind::Video,
|
||||
};
|
||||
let codec = Codec::from_coding_type(s.coding_type);
|
||||
Stream {
|
||||
kind,
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
frame_rate: format_framerate(s.video_rate),
|
||||
channels: format_channels(s.audio_format),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
match s.stream_type {
|
||||
1 | 6 | 7 => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
frame_rate: format_framerate(s.video_rate),
|
||||
hdr: match s.dynamic_range {
|
||||
1 => HdrFormat::Hdr10,
|
||||
2 => HdrFormat::DolbyVision,
|
||||
_ => HdrFormat::Sdr,
|
||||
},
|
||||
color_space: match s.color_space {
|
||||
1 => ColorSpace::Bt709,
|
||||
2 => ColorSpace::Bt2020,
|
||||
_ => ColorSpace::Unknown,
|
||||
},
|
||||
secondary: s.secondary,
|
||||
label: match s.stream_type {
|
||||
7 => "Dolby Vision EL".to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
}),
|
||||
2 | 5 => Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}),
|
||||
3 => Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false, // TODO: parse from MPLS stream attributes
|
||||
}),
|
||||
_ => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: String::new(),
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
@@ -591,7 +731,7 @@ impl Disc {
|
||||
playlist_id,
|
||||
duration_secs,
|
||||
size_bytes: total_size,
|
||||
clip_count,
|
||||
clips,
|
||||
streams,
|
||||
extents,
|
||||
})
|
||||
@@ -601,13 +741,35 @@ impl Disc {
|
||||
// ─── Decrypted reader ──────────────────────────────────────────────────────
|
||||
|
||||
/// A reader that reads m2ts content, decrypting transparently if needed.
|
||||
///
|
||||
/// Adaptive read strategy:
|
||||
/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed
|
||||
/// - On read error: halves batch size, brief pause for drive recovery
|
||||
/// - On repeated errors: reduces disc spin speed (scratched region)
|
||||
/// - On success streak: ramps batch back up, then restores disc speed
|
||||
/// - At minimum batch + still failing: retries once, then skips + zero-fills
|
||||
pub struct ContentReader<'a> {
|
||||
session: &'a mut DriveSession,
|
||||
aacs: Option<&'a AacsState>,
|
||||
extents: Vec<Extent>,
|
||||
current_extent: usize,
|
||||
current_offset: u32, // sectors into current extent
|
||||
current_offset: u32,
|
||||
unit_key_idx: usize,
|
||||
read_buf: Vec<u8>,
|
||||
buf_pos: usize,
|
||||
buf_len: usize,
|
||||
/// Current batch size in sectors (adapts on errors)
|
||||
batch_sectors: u16,
|
||||
/// Consecutive successful batch reads
|
||||
ok_streak: u32,
|
||||
/// Consecutive errors at current position
|
||||
error_streak: u32,
|
||||
/// Current speed tier index (0 = max, higher = slower)
|
||||
speed_tier: usize,
|
||||
/// Last time maintain_speed was called
|
||||
last_speed_maintain: std::time::Instant,
|
||||
/// Total read errors encountered
|
||||
pub errors: u32,
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
@@ -631,6 +793,14 @@ impl Disc {
|
||||
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
|
||||
})?;
|
||||
|
||||
if !session.is_unlocked() {
|
||||
let _ = session.init();
|
||||
}
|
||||
|
||||
let speed_cdb = crate::scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = session.scsi_execute(&speed_cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
|
||||
Ok(ContentReader {
|
||||
session,
|
||||
aacs: self.aacs.as_ref(),
|
||||
@@ -638,65 +808,265 @@ impl Disc {
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
unit_key_idx: 0,
|
||||
read_buf: Vec::with_capacity(MAX_BATCH_SECTORS as usize * 2048),
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
batch_sectors: MAX_BATCH_SECTORS,
|
||||
ok_streak: 0,
|
||||
error_streak: 0,
|
||||
speed_tier: 0,
|
||||
last_speed_maintain: std::time::Instant::now(),
|
||||
errors: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the maximum transfer size in sectors for a device.
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// Returns a value aligned to 3 sectors (one aligned unit).
|
||||
fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
// Extract block device name: /dev/sr0 → sr0
|
||||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||||
if !dev_name.is_empty() {
|
||||
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", dev_name);
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB on disc = 2048 bytes)
|
||||
let sectors = (kb / 2) as u16;
|
||||
// Align down to 3 (one aligned unit) and cap at a reasonable max
|
||||
let aligned = (sectors / 3) * 3;
|
||||
if aligned >= MIN_BATCH_SECTORS {
|
||||
return aligned.min(MAX_BATCH_SECTORS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: conservative default
|
||||
MAX_BATCH_SECTORS
|
||||
}
|
||||
|
||||
/// Read strategy constants
|
||||
const MAX_BATCH_SECTORS: u16 = 510; // 170 aligned units ≈ 1MB (kernel caps to hw limit)
|
||||
const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
|
||||
const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
|
||||
const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
|
||||
const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed
|
||||
|
||||
/// Disc speed tiers (KB/s for SET CD SPEED).
|
||||
/// Blu-ray: 1x=4500, 2x=9000, 4x=18000, 8x=36000, 12x=54000
|
||||
const SPEED_TIERS: &[u16] = &[
|
||||
0xFFFF, // tier 0: max (drive decides, typically 8-12x)
|
||||
36000, // tier 1: 8x BD (~36 MB/s)
|
||||
18000, // tier 2: 4x BD (~18 MB/s)
|
||||
9000, // tier 3: 2x BD (~9 MB/s)
|
||||
4500, // tier 4: 1x BD (~4.5 MB/s) — last resort
|
||||
];
|
||||
|
||||
impl<'a> ContentReader<'a> {
|
||||
/// Total bytes across all extents (for progress display).
|
||||
pub fn total_bytes(&self) -> u64 {
|
||||
self.extents.iter().map(|e| e.sector_count as u64 * 2048).sum()
|
||||
}
|
||||
|
||||
/// Read the next aligned unit (6144 bytes).
|
||||
/// Automatically decrypted if AACS keys are available.
|
||||
/// Returns None when all extents are exhausted.
|
||||
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
// Refill buffer if empty
|
||||
if self.buf_pos >= self.buf_len {
|
||||
if !self.fill_buffer()? {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract one aligned unit from buffer
|
||||
let start = self.buf_pos * crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let end = start + crate::aacs::ALIGNED_UNIT_LEN;
|
||||
let mut unit = self.read_buf[start..end].to_vec();
|
||||
|
||||
// Decrypt if needed
|
||||
self.decrypt_unit(&mut unit);
|
||||
self.buf_pos += 1;
|
||||
Ok(Some(unit))
|
||||
}
|
||||
|
||||
/// Read the next batch of aligned units, decrypted in-place.
|
||||
/// Returns the decrypted data as a single contiguous slice.
|
||||
/// More efficient than read_unit() — one write_all() per batch instead of per unit.
|
||||
/// Returns None when all extents are exhausted.
|
||||
pub fn read_batch(&mut self) -> Result<Option<&[u8]>> {
|
||||
if !self.fill_buffer()? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extent = &self.extents[self.current_extent];
|
||||
let lba = extent.start_lba + self.current_offset;
|
||||
// Decrypt all units in the buffer in-place
|
||||
let unit_len = crate::aacs::ALIGNED_UNIT_LEN;
|
||||
if let Some(aacs) = &self.aacs {
|
||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
let rdk = aacs.read_data_key.as_ref();
|
||||
|
||||
// Read 3 sectors (one aligned unit)
|
||||
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
|
||||
for i in 0..3u32 {
|
||||
let offset = (i as usize) * 2048;
|
||||
let mut sector = [0u8; 2048];
|
||||
session_read_sector(self.session, lba + i, &mut sector)?;
|
||||
unit[offset..offset + 2048].copy_from_slice(§or);
|
||||
for i in 0..self.buf_len {
|
||||
let start = i * unit_len;
|
||||
let end = start + unit_len;
|
||||
let unit = &mut self.read_buf[start..end];
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
crate::aacs::decrypt_unit_full(unit, &uk, rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt if needed
|
||||
let total_bytes = self.buf_len * unit_len;
|
||||
self.buf_pos = self.buf_len; // mark fully consumed
|
||||
Ok(Some(&self.read_buf[..total_bytes]))
|
||||
}
|
||||
|
||||
/// Decrypt a single aligned unit in-place if needed.
|
||||
fn decrypt_unit(&self, unit: &mut [u8]) {
|
||||
if let Some(aacs) = &self.aacs {
|
||||
if crate::aacs::is_unit_encrypted(&unit) {
|
||||
if crate::aacs::is_unit_encrypted(unit) {
|
||||
let uk = aacs.unit_keys.get(self.unit_key_idx)
|
||||
.map(|(_, k)| *k)
|
||||
.unwrap_or([0u8; 16]);
|
||||
|
||||
crate::aacs::decrypt_unit_full(
|
||||
&mut unit,
|
||||
unit,
|
||||
&uk,
|
||||
aacs.read_data_key.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance position
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= extent.sector_count {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
Ok(Some(unit))
|
||||
}
|
||||
}
|
||||
|
||||
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00, 0x00, 0x01, 0x00,
|
||||
];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
|
||||
Ok(())
|
||||
/// Read sectors via standard READ(10) 0x00.
|
||||
/// calibration primers. Standard reads are faster on most drives.
|
||||
fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> {
|
||||
self.session.read_content(lba, count, &mut self.read_buf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set disc spin speed via SCSI SET CD SPEED.
|
||||
fn set_speed(&mut self, tier: usize) {
|
||||
let tier = tier.min(SPEED_TIERS.len() - 1);
|
||||
if tier != self.speed_tier {
|
||||
self.speed_tier = tier;
|
||||
let speed_kbs = SPEED_TIERS[tier];
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = self.session.scsi_execute(
|
||||
&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a batch of sectors into the internal buffer.
|
||||
///
|
||||
/// Adaptive strategy:
|
||||
/// 1. Read at current batch size
|
||||
/// 2. On success: ramp batch up (double after 5 successes),
|
||||
/// then restore disc speed (after 50 at max batch)
|
||||
/// 3. On error: halve batch, pause. After 3 consecutive errors,
|
||||
/// also reduce disc spin speed (scratched/damaged region).
|
||||
/// 4. At min batch + still failing: retry once, then skip + zero-fill.
|
||||
fn fill_buffer(&mut self) -> Result<bool> {
|
||||
loop {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let ext_start = self.extents[self.current_extent].start_lba;
|
||||
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||
let remaining = ext_sectors - self.current_offset;
|
||||
|
||||
// Align to 3 sectors (one aligned unit)
|
||||
let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors_to_read = sectors_to_read - (sectors_to_read % 3);
|
||||
if sectors_to_read == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let byte_count = sectors_to_read as usize * 2048;
|
||||
self.read_buf.resize(byte_count, 0);
|
||||
|
||||
match self.read_sectors(lba, sectors_to_read) {
|
||||
Ok(_) => {
|
||||
self.buf_len = sectors_to_read as usize / 3;
|
||||
self.buf_pos = 0;
|
||||
self.current_offset += sectors_to_read as u32;
|
||||
self.error_streak = 0;
|
||||
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
// Ramp up: batch size first, then disc speed
|
||||
self.ok_streak += 1;
|
||||
if self.batch_sectors < MAX_BATCH_SECTORS {
|
||||
if self.ok_streak >= RAMP_BATCH_AFTER {
|
||||
self.batch_sectors = (self.batch_sectors * 2).min(MAX_BATCH_SECTORS);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
} else if self.speed_tier > 0 && self.ok_streak >= RAMP_SPEED_AFTER {
|
||||
// At max batch for a while — try faster disc speed
|
||||
self.set_speed(self.speed_tier - 1);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Err(_) => {
|
||||
self.errors += 1;
|
||||
self.error_streak += 1;
|
||||
self.ok_streak = 0;
|
||||
|
||||
// Reduce disc speed after repeated errors (physical problem)
|
||||
if self.error_streak >= SLOW_SPEED_AFTER
|
||||
&& self.speed_tier < SPEED_TIERS.len() - 1
|
||||
{
|
||||
self.set_speed(self.speed_tier + 1);
|
||||
self.error_streak = 0; // reset — give new speed a chance
|
||||
}
|
||||
|
||||
if self.batch_sectors > MIN_BATCH_SECTORS {
|
||||
// Shrink batch and retry
|
||||
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
// At minimum batch — retry once with longer pause
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
|
||||
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
|
||||
self.buf_len = 1;
|
||||
self.buf_pos = 0;
|
||||
self.error_streak = 0;
|
||||
self.current_offset += MIN_BATCH_SECTORS as u32;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Still failing — skip this unit (zero-fill)
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
}
|
||||
self.read_buf.resize(crate::aacs::ALIGNED_UNIT_LEN, 0);
|
||||
self.read_buf.fill(0);
|
||||
self.buf_len = 1;
|
||||
self.buf_pos = 0;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Format helpers ────────────────────────────────────────────────────────
|
||||
|
||||
+165
-41
@@ -30,22 +30,32 @@ pub struct DriveSession {
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive, identify it, match a profile, and unlock for raw reads.
|
||||
/// Open a drive — identify, wait for disc, and unlock for raw reads.
|
||||
///
|
||||
/// This is the standard entry point. After `open()`, the drive is ready
|
||||
/// for sector reads, disc scanning, and content extraction.
|
||||
/// This is the standard entry point. After `open()`, the drive is
|
||||
/// ready for scanning and content reads.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive WITHOUT unlocking.
|
||||
/// Open a drive and immediately init for raw reads.
|
||||
///
|
||||
/// Used when AACS authentication must happen before raw mode.
|
||||
/// The AACS SCSI handshake requires the drive's standard firmware
|
||||
/// state — unlocking puts the drive in vendor-specific raw mode
|
||||
/// which disables the AACS layer.
|
||||
/// Use this when you need raw disc access without AACS (e.g. capture,
|
||||
/// sector dumps). Skips AACS authentication — cannot be done after init.
|
||||
pub fn open_unlocked(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive — identify only, no wait, no unlock.
|
||||
///
|
||||
/// Low-level entry point. Caller is responsible for wait_ready()
|
||||
/// and unlock() ordering.
|
||||
pub fn open_no_unlock(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
@@ -85,14 +95,35 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for the drive to become ready (disc spun up).
|
||||
/// Polls TEST UNIT READY up to 30 seconds.
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self.scsi.as_mut().execute(
|
||||
&tur, crate::scsi::DataDirection::None, &mut buf, 5000
|
||||
).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
Err(Error::DeviceNotFound {
|
||||
path: format!("{}: drive not ready after 30s", self.device_path),
|
||||
})
|
||||
}
|
||||
|
||||
/// Device path this session was opened on.
|
||||
pub fn device_path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
|
||||
/// Activate raw disc access mode (vendor-specific unlock).
|
||||
pub fn unlock(&mut self) -> Result<()> {
|
||||
self.platform.unlock(self.scsi.as_mut())
|
||||
///
|
||||
/// This is the ONLY entry point for activating raw disc access.
|
||||
/// Handles the full x86 dispatch sequence internally:
|
||||
/// unlock → [load_firmware if cold] × 6 → calibrate × 6 → registers
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
self.platform.init(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is active.
|
||||
@@ -100,37 +131,12 @@ impl DriveSession {
|
||||
self.platform.is_unlocked()
|
||||
}
|
||||
|
||||
/// Read drive status and feature flags.
|
||||
pub fn status(&mut self) -> Result<DriveStatus> {
|
||||
self.platform.status(self.scsi.as_mut())
|
||||
/// Called per zone change during content reads.
|
||||
pub fn set_read_speed(&mut self, lba: u32) -> Result<()> {
|
||||
self.platform.set_read_speed(self.scsi.as_mut(), lba)
|
||||
}
|
||||
|
||||
/// Read drive configuration block.
|
||||
pub fn read_config(&mut self) -> Result<Vec<u8>> {
|
||||
self.platform.read_config(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Read hardware register.
|
||||
pub fn read_register(&mut self, index: u8) -> Result<[u8; 16]> {
|
||||
self.platform.read_register(self.scsi.as_mut(), index)
|
||||
}
|
||||
|
||||
/// Calibrate read speed for the current disc.
|
||||
pub fn calibrate(&mut self) -> Result<()> {
|
||||
self.platform.calibrate(self.scsi.as_mut())
|
||||
}
|
||||
|
||||
/// Read raw disc sectors via platform-specific command.
|
||||
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
|
||||
}
|
||||
|
||||
/// Platform-specific probe command.
|
||||
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
|
||||
}
|
||||
|
||||
/// Standard SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
||||
/// SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
@@ -144,6 +150,36 @@ impl DriveSession {
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// SCSI READ(10) for m2ts content — bulk reads with longer timeout.
|
||||
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Eject the disc tray.
|
||||
///
|
||||
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
|
||||
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
|
||||
// START STOP UNIT: LoEj=1, Start=0
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
@@ -156,6 +192,94 @@ impl DriveSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover optical drives on the system.
|
||||
///
|
||||
/// Scans `/dev/sg0` through `/dev/sg15` (Linux SCSI Generic devices),
|
||||
/// sends INQUIRY to each, and returns paths for optical drives (device type 5).
|
||||
/// Always uses sg devices — sr devices have kernel-level speed management
|
||||
/// that interferes with raw disc access.
|
||||
///
|
||||
/// Returns a list of (device_path, DriveId) for each found drive.
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
// INQUIRY device type 5 = CD/DVD/BD
|
||||
// We check by trying to match a profile — only optical drives have profiles
|
||||
let profiles = match profile::load_bundled() {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if profile::find_by_drive_id(&profiles, &id).is_some() {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drives
|
||||
}
|
||||
|
||||
/// Find the first optical drive on the system.
|
||||
/// Returns the sg device path, or None if no drive found.
|
||||
pub fn find_drive() -> Option<String> {
|
||||
find_drives().into_iter().next().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
/// Resolve a device path to the correct sg device.
|
||||
///
|
||||
/// If the user passes `/dev/sr0`, maps it to the corresponding `/dev/sg*`.
|
||||
/// If they pass `/dev/sg*`, validates it exists.
|
||||
/// Returns `(resolved_path, warning)` where warning is set if the path was remapped.
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
// Already an sg device — use as-is
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
|
||||
// sr device — find the matching sg device by comparing INQUIRY data
|
||||
if path.contains("/sr") {
|
||||
// Open the sr device to get its identity
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
|
||||
// Find matching sg device
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access",
|
||||
path, sg_path
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
|
||||
// No sg match found — fall back to sr with warning
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — no matching sg device found, performance may be limited",
|
||||
path
|
||||
);
|
||||
return Ok((path.to_string(), Some(warning)));
|
||||
}
|
||||
|
||||
// Unknown device type — use as-is
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
}
|
||||
|
||||
/// Create the platform-specific driver for a given chipset.
|
||||
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
|
||||
match profile.chipset {
|
||||
|
||||
@@ -32,6 +32,11 @@ pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_DISC_ERROR: u16 = 6000;
|
||||
pub const E_AACS_ERROR: u16 = 7000;
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
pub const E_KEYDB_INVALID: u16 = 8002;
|
||||
pub const E_KEYDB_WRITE: u16 = 8003;
|
||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -52,6 +57,11 @@ pub enum Error {
|
||||
IoError { source: std::io::Error },
|
||||
DiscError { detail: String },
|
||||
AacsError { detail: String },
|
||||
KeydbConnect { host: String },
|
||||
KeydbHttp { status: u16 },
|
||||
KeydbInvalid,
|
||||
KeydbWrite { path: String },
|
||||
KeydbParse,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -72,6 +82,11 @@ impl Error {
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::DiscError { .. } => E_DISC_ERROR,
|
||||
Error::AacsError { .. } => E_AACS_ERROR,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||
Error::KeydbParse => E_KEYDB_PARSE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +130,16 @@ impl std::fmt::Display for Error {
|
||||
write!(f, "E{}: {}", E_DISC_ERROR, detail),
|
||||
Error::AacsError { detail } =>
|
||||
write!(f, "E{}: {}", E_AACS_ERROR, detail),
|
||||
Error::KeydbConnect { host } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_CONNECT, host),
|
||||
Error::KeydbHttp { status } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_HTTP, status),
|
||||
Error::KeydbInvalid =>
|
||||
write!(f, "E{}", E_KEYDB_INVALID),
|
||||
Error::KeydbWrite { path } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_WRITE, path),
|
||||
Error::KeydbParse =>
|
||||
write!(f, "E{}", E_KEYDB_PARSE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -39,6 +39,9 @@ pub struct DriveId {
|
||||
/// Format: CCYYMMDDHHMI (12 ASCII characters)
|
||||
pub firmware_date: String,
|
||||
|
||||
/// Drive serial number — GET CONFIGURATION Feature 0108h
|
||||
pub serial_number: String,
|
||||
|
||||
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
||||
pub raw_inquiry: Vec<u8>,
|
||||
}
|
||||
@@ -63,10 +66,29 @@ impl DriveId {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(Self::from_inquiry(&inquiry, &firmware_date))
|
||||
// GET CONFIGURATION Feature 0108h — Serial Number
|
||||
let mut gc_serial = vec![0u8; 256];
|
||||
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) {
|
||||
if r.bytes_transferred > 12 {
|
||||
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
|
||||
.trim().to_string()
|
||||
} else { String::new() }
|
||||
} else { String::new() };
|
||||
|
||||
Ok(DriveId {
|
||||
vendor_id: ascii_field(&inquiry, 8, 16),
|
||||
product_id: ascii_field(&inquiry, 16, 32),
|
||||
product_revision: ascii_field(&inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(&inquiry, 36, 43),
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build identity from raw INQUIRY bytes and firmware date string.
|
||||
/// Used by tests and when serial isn't available.
|
||||
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
|
||||
DriveId {
|
||||
vendor_id: ascii_field(inquiry, 8, 16),
|
||||
@@ -74,6 +96,7 @@ impl DriveId {
|
||||
product_revision: ascii_field(inquiry, 32, 36),
|
||||
vendor_specific: ascii_field(inquiry, 36, 43),
|
||||
firmware_date: firmware_date.to_string(),
|
||||
serial_number: String::new(),
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
+244
-165
@@ -2,29 +2,24 @@
|
||||
//!
|
||||
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
|
||||
//! in BDMV/JAR/. These contain .class files with string constants that label
|
||||
//! audio and subtitle tracks (e.g. "eng_ADES_US_" = Descriptive Audio US).
|
||||
//! audio and subtitle tracks.
|
||||
//!
|
||||
//! with no way to tell them apart (e.g. 3x "AC-3 5.1 English").
|
||||
//! Multiple label formats exist across studios:
|
||||
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
|
||||
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
|
||||
//!
|
||||
//! The JAR is a ZIP file. Each .class file has a Java constant pool containing
|
||||
//! UTF-8 string literals. We scan for patterns like:
|
||||
//! {lang}_{codec}_{variant}_ → audio track labels
|
||||
//! {lang}_PGStream{n} → subtitle track labels
|
||||
//! MAIN_FEATURE → playlist identification
|
||||
//! FORCED_TRAILER → forced content identification
|
||||
//!
|
||||
//! This is best-effort: if the JAR doesn't contain labels or the format
|
||||
//! is unexpected, we return empty results. The caller falls back to
|
||||
//! showing streams without labels.
|
||||
//! Labels match to streams by STN index — the Nth audio label in the JAR
|
||||
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
|
||||
//! by the BD-J API where stream numbers = STN indices (1-based).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Labels extracted from a BD-J JAR file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct JarLabels {
|
||||
/// Audio track labels in STN order: (language, codec_hint, variant, raw_label)
|
||||
/// Audio track labels in STN order
|
||||
pub audio: Vec<TrackLabel>,
|
||||
/// Subtitle track labels: (language, stream_index, raw_label)
|
||||
/// Subtitle track labels in STN order
|
||||
pub subtitle: Vec<TrackLabel>,
|
||||
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
|
||||
pub playlists: Vec<String>,
|
||||
@@ -33,155 +28,230 @@ pub struct JarLabels {
|
||||
/// A parsed track label from the JAR.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackLabel {
|
||||
/// ISO 639-2 language code (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Codec or type hint (e.g. "MLP", "AC3", "ADES", "PGStream")
|
||||
pub hint: String,
|
||||
/// Variant or region (e.g. "US", "UK", "3", "4")
|
||||
pub variant: String,
|
||||
/// Human-readable description derived from the label
|
||||
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
|
||||
pub description: String,
|
||||
/// ISO 639-2 language code if available (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
|
||||
pub codec_hint: String,
|
||||
/// The raw string from the class file
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
impl TrackLabel {
|
||||
/// Parse a label string like "eng_ADES_US_" or "dan_PGStream4"
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
let clean = s.trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.splitn(3, '_').collect();
|
||||
if parts.len() < 2 {
|
||||
return None;
|
||||
/// Extract track labels from a JAR file (raw ZIP bytes).
|
||||
///
|
||||
/// Tries multiple format parsers. Returns None if no labels found.
|
||||
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
|
||||
let strings = extract_all_jar_strings(jar_data)?;
|
||||
|
||||
// Try each format parser in order
|
||||
try_textfield_format(&strings)
|
||||
.or_else(|| try_label_format(&strings))
|
||||
.or_else(|| try_playlist_only(&strings))
|
||||
}
|
||||
|
||||
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
|
||||
//
|
||||
// Pattern: "TextField,Audio{N},{description},..."
|
||||
// "TextField,Subtitle{N},{description},..."
|
||||
// Found in: Civil War, and similar A24/Lionsgate discs
|
||||
|
||||
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
|
||||
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if s.starts_with("TextField,Audio") {
|
||||
// TextField,Audio{N},{description},...
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1]; // "Audio1", "Audio2", etc.
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Audio") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.is_empty() {
|
||||
audio.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if s.starts_with("TextField,Subtitle") {
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1];
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Subtitle") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
|
||||
subtitle.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let language = parts[0].to_string();
|
||||
// Language should be 2-3 lowercase letters
|
||||
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by index to ensure STN order
|
||||
audio.sort_by_key(|a| a.0);
|
||||
subtitle.sort_by_key(|s| s.0);
|
||||
|
||||
Some(JarLabels {
|
||||
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
playlists,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
|
||||
//
|
||||
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
|
||||
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
|
||||
|
||||
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitle = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if let Some(label) = parse_label_string(s) {
|
||||
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
audio.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
subtitle.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let hint = parts[1].to_string();
|
||||
let variant = if parts.len() > 2 { parts[2].to_string() } else { String::new() };
|
||||
|
||||
let description = match hint.as_str() {
|
||||
"MLP" => "TrueHD".to_string(),
|
||||
"AC3" => {
|
||||
if variant.is_empty() { "compatibility".to_string() }
|
||||
else { variant.clone() }
|
||||
}
|
||||
"DTS" => "DTS".to_string(),
|
||||
"LPCM" => "LPCM".to_string(),
|
||||
"ADES" => {
|
||||
if variant.is_empty() { "Descriptive Audio".to_string() }
|
||||
else { format!("Descriptive Audio ({})", variant) }
|
||||
}
|
||||
h if h.starts_with("AudioStream") => String::new(), // generic, no extra info
|
||||
h if h.starts_with("PGStream") => String::new(), // generic subtitle
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
Some(TrackLabel {
|
||||
language,
|
||||
hint,
|
||||
variant,
|
||||
description,
|
||||
raw: s.to_string(),
|
||||
})
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
/// Is this an audio track label?
|
||||
fn is_audio(&self) -> bool {
|
||||
matches!(self.hint.as_str(), "MLP" | "AC3" | "DTS" | "LPCM" | "ADES")
|
||||
|| self.hint.starts_with("AudioStream")
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
/// Is this a subtitle track label?
|
||||
fn is_subtitle(&self) -> bool {
|
||||
self.hint.starts_with("PGStream")
|
||||
Some(JarLabels { audio, subtitle, playlists })
|
||||
}
|
||||
|
||||
struct ParsedLabel {
|
||||
description: String,
|
||||
language: String,
|
||||
codec_hint: String,
|
||||
raw: String,
|
||||
is_audio: bool,
|
||||
is_subtitle: bool,
|
||||
}
|
||||
|
||||
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
|
||||
let clean = s.trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.splitn(3, '_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let language = parts[0];
|
||||
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hint = parts[1];
|
||||
let variant = if parts.len() > 2 { parts[2] } else { "" };
|
||||
|
||||
// Only set description when the JAR adds info beyond what MPLS provides.
|
||||
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
|
||||
let (description, codec_hint, is_audio, is_subtitle) = match hint {
|
||||
"MLP" => (String::new(), "MLP".to_string(), true, false),
|
||||
"AC3" => (String::new(), "AC3".to_string(), true, false),
|
||||
"DTS" => (String::new(), "DTS".to_string(), true, false),
|
||||
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
|
||||
"ADES" => {
|
||||
// Descriptive Audio — real value-add, can't be derived from MPLS
|
||||
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
|
||||
else { format!("Descriptive Audio ({})", variant) };
|
||||
(d, "ADES".to_string(), true, false)
|
||||
}
|
||||
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
|
||||
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedLabel {
|
||||
description, language: language.to_string(), codec_hint,
|
||||
raw: s.to_string(), is_audio, is_subtitle,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
|
||||
//
|
||||
// Some JARs have no track labels but do have playlist purpose markers.
|
||||
|
||||
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut playlists = Vec::new();
|
||||
for s in strings {
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
if playlists.is_empty() { return None; }
|
||||
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
|
||||
}
|
||||
|
||||
// ── Common helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
|
||||
if matches!(s.as_ref(),
|
||||
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
|
||||
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
|
||||
"commentary_extras" | "extras"
|
||||
) {
|
||||
if !playlists.iter().any(|p| p == s) {
|
||||
playlists.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract track labels from a JAR file (raw ZIP bytes).
|
||||
///
|
||||
/// Returns None if the JAR can't be parsed or contains no labels.
|
||||
/// This is best-effort — failure is not an error.
|
||||
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
|
||||
/// Extract all UTF-8 string constants from all .class files in a JAR.
|
||||
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
|
||||
let cursor = std::io::Cursor::new(jar_data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut all_audio = Vec::new();
|
||||
let mut all_subtitle = Vec::new();
|
||||
let mut all_playlists = Vec::new();
|
||||
let mut all_strings = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).ok()?;
|
||||
if !file.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
if !file.name().ends_with(".class") { continue; }
|
||||
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).ok()?;
|
||||
|
||||
let strings = extract_class_strings(&data);
|
||||
|
||||
for s in &strings {
|
||||
// Track labels: {lang}_{type}_{variant}_
|
||||
if let Some(label) = TrackLabel::parse(s) {
|
||||
if label.is_audio() && !all_audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
all_audio.push(label);
|
||||
} else if label.is_subtitle() && !all_subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
all_subtitle.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist purpose labels
|
||||
if matches!(s.as_str(),
|
||||
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
|
||||
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
|
||||
"commentary_extras" | "extras"
|
||||
) {
|
||||
if !all_playlists.contains(s) {
|
||||
all_playlists.push(s.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
extract_class_strings(&data, &mut all_strings);
|
||||
}
|
||||
|
||||
if all_audio.is_empty() && all_subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JarLabels {
|
||||
audio: all_audio,
|
||||
subtitle: all_subtitle,
|
||||
playlists: all_playlists,
|
||||
})
|
||||
if all_strings.is_empty() { return None; }
|
||||
Some(all_strings)
|
||||
}
|
||||
|
||||
/// Extract all UTF-8 string constants from a Java .class file's constant pool.
|
||||
///
|
||||
/// Java class file format:
|
||||
/// [0:4] magic (0xCAFEBABE)
|
||||
/// [4:6] minor version
|
||||
/// [6:8] major version
|
||||
/// [8:10] constant_pool_count
|
||||
/// [10:] constant_pool entries
|
||||
///
|
||||
/// CONSTANT_Utf8 (tag=1): u8 tag + u16 length + bytes
|
||||
/// We only extract these — they contain all string literals.
|
||||
fn extract_class_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
|
||||
// Verify Java class magic
|
||||
/// Extract UTF-8 string constants from a Java .class file's constant pool.
|
||||
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
|
||||
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
|
||||
return strings;
|
||||
return;
|
||||
}
|
||||
|
||||
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
|
||||
let mut pos = 10;
|
||||
let mut entry = 1;
|
||||
|
||||
// Parse constant pool entries
|
||||
let mut entry = 1; // constant pool is 1-indexed
|
||||
while entry < cp_count && pos < data.len() {
|
||||
let tag = data[pos];
|
||||
pos += 1;
|
||||
@@ -195,32 +265,21 @@ fn extract_class_strings(data: &[u8]) -> Vec<String> {
|
||||
if pos + len > data.len() { break; }
|
||||
|
||||
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
|
||||
// Only keep strings that look like track labels
|
||||
// Must contain underscore and be reasonable length
|
||||
if s.len() >= 5 && s.len() <= 100 && s.contains('_') {
|
||||
strings.push(s.to_string());
|
||||
if s.len() >= 3 && s.len() <= 500 {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
// CONSTANT_Integer, CONSTANT_Float
|
||||
3 | 4 => { pos += 4; }
|
||||
// CONSTANT_Long, CONSTANT_Double (take 2 entries)
|
||||
5 | 6 => { pos += 8; entry += 1; }
|
||||
// CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType
|
||||
7 | 8 | 16 => { pos += 2; }
|
||||
// CONSTANT_Fieldref, CONSTANT_Methodref, CONSTANT_InterfaceMethodref,
|
||||
// CONSTANT_NameAndType, CONSTANT_InvokeDynamic
|
||||
9 | 10 | 11 | 12 | 18 => { pos += 4; }
|
||||
// CONSTANT_MethodHandle
|
||||
15 => { pos += 3; }
|
||||
// Unknown tag — can't continue parsing safely
|
||||
_ => { break; }
|
||||
}
|
||||
entry += 1;
|
||||
}
|
||||
|
||||
strings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -228,36 +287,56 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_audio_labels() {
|
||||
let l = TrackLabel::parse("eng_MLP_").unwrap();
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.hint, "MLP");
|
||||
assert_eq!(l.description, "TrueHD");
|
||||
assert!(l.is_audio());
|
||||
|
||||
let l = TrackLabel::parse("eng_ADES_US_").unwrap();
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.hint, "ADES");
|
||||
assert_eq!(l.variant, "US");
|
||||
assert_eq!(l.description, "Descriptive Audio (US)");
|
||||
assert!(l.is_audio());
|
||||
|
||||
let l = TrackLabel::parse("fra_AudioStream3").unwrap();
|
||||
assert_eq!(l.language, "fra");
|
||||
assert!(l.is_audio());
|
||||
fn test_textfield_audio() {
|
||||
let strings = vec![
|
||||
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
|
||||
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
|
||||
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
|
||||
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
|
||||
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subtitle_labels() {
|
||||
let l = TrackLabel::parse("dan_PGStream4").unwrap();
|
||||
assert_eq!(l.language, "dan");
|
||||
assert!(l.is_subtitle());
|
||||
fn test_textfield_subtitle_skips_none() {
|
||||
let strings = vec![
|
||||
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
|
||||
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
|
||||
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 2);
|
||||
assert_eq!(labels.subtitle[0].description, "English SDH");
|
||||
assert_eq!(labels.subtitle[1].description, "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_non_labels() {
|
||||
assert!(TrackLabel::parse("substring").is_none());
|
||||
assert!(TrackLabel::parse("equals").is_none());
|
||||
assert!(TrackLabel::parse("Code").is_none());
|
||||
fn test_label_format_audio() {
|
||||
let strings = vec![
|
||||
"eng_MLP_".to_string(),
|
||||
"eng_ADES_US_".to_string(),
|
||||
"fra_AudioStream3".to_string(),
|
||||
];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
|
||||
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_subtitle() {
|
||||
let strings = vec!["dan_PGStream4".to_string()];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_labels() {
|
||||
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
|
||||
assert!(try_textfield_format(&strings).is_none());
|
||||
assert!(try_label_format(&strings).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
//! KEYDB.cfg updater — HTTP GET, unzip, verify, save.
|
||||
//!
|
||||
//! Zero external HTTP dependencies. Raw TCP for HTTP GET.
|
||||
//! Uses `zip` and `flate2` (already in deps) for extraction.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Standard keydb storage path.
|
||||
pub fn default_path() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").map_err(|_| Error::KeydbWrite {
|
||||
path: "HOME".into(),
|
||||
})?;
|
||||
Ok(PathBuf::from(home).join(".config").join("freemkv").join("keydb.cfg"))
|
||||
}
|
||||
|
||||
/// Download a KEYDB from a URL, verify, save to the standard path.
|
||||
pub fn update(url: &str) -> Result<UpdateResult> {
|
||||
let body = http_get(url)?;
|
||||
save(&body)
|
||||
}
|
||||
|
||||
/// Verify and save raw keydb bytes (plain text, .zip, or .gz).
|
||||
pub fn save(data: &[u8]) -> Result<UpdateResult> {
|
||||
let text = if data.starts_with(b"PK\x03\x04") {
|
||||
extract_zip(data)?
|
||||
} else if data.starts_with(&[0x1f, 0x8b]) {
|
||||
let mut dec = flate2::read::GzDecoder::new(data);
|
||||
let mut out = String::new();
|
||||
dec.read_to_string(&mut out).map_err(|_| Error::KeydbParse)?;
|
||||
out
|
||||
} else {
|
||||
String::from_utf8(data.to_vec()).map_err(|_| Error::KeydbParse)?
|
||||
};
|
||||
|
||||
let entries = text.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim();
|
||||
t.starts_with("0x") || t.starts_with("| DK") || t.starts_with("| PK") || t.starts_with("| HC")
|
||||
})
|
||||
.count();
|
||||
|
||||
if entries == 0 {
|
||||
return Err(Error::KeydbInvalid);
|
||||
}
|
||||
|
||||
let path = default_path()?;
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
}
|
||||
std::fs::write(&path, &text).map_err(|_| Error::KeydbWrite {
|
||||
path: path.display().to_string(),
|
||||
})?;
|
||||
|
||||
Ok(UpdateResult { path, entries, bytes: text.len() })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateResult {
|
||||
pub path: PathBuf,
|
||||
pub entries: usize,
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
fn http_get(url: &str) -> Result<Vec<u8>> {
|
||||
let (host, port, path) = parse_url(url)?;
|
||||
|
||||
for _ in 0..5 {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let mut stream = TcpStream::connect(&addr).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
|
||||
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept-Encoding: identity\r\n\r\n",
|
||||
path, host
|
||||
);
|
||||
stream.write_all(request.as_bytes()).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).map_err(|_| Error::KeydbConnect {
|
||||
host: host.clone(),
|
||||
})?;
|
||||
|
||||
let header_end = find_header_end(&response).ok_or(Error::KeydbParse)?;
|
||||
let headers = std::str::from_utf8(&response[..header_end]).unwrap_or("");
|
||||
let body = &response[header_end + 4..];
|
||||
|
||||
if let Some(location) = extract_header(headers, "Location") {
|
||||
return http_get(&location);
|
||||
}
|
||||
|
||||
let status = parse_status(headers);
|
||||
if status != 200 {
|
||||
return Err(Error::KeydbHttp { status });
|
||||
}
|
||||
|
||||
return Ok(body.to_vec());
|
||||
}
|
||||
|
||||
Err(Error::KeydbHttp { status: 302 })
|
||||
}
|
||||
|
||||
fn parse_url(url: &str) -> Result<(String, u16, String)> {
|
||||
let url = url.strip_prefix("http://").ok_or(Error::KeydbParse)?;
|
||||
let (host_port, path) = match url.find('/') {
|
||||
Some(i) => (&url[..i], &url[i..]),
|
||||
None => (url, "/"),
|
||||
};
|
||||
let (host, port) = match host_port.find(':') {
|
||||
Some(i) => (&host_port[..i], host_port[i+1..].parse().unwrap_or(80)),
|
||||
None => (host_port, 80u16),
|
||||
};
|
||||
Ok((host.to_string(), port, path.to_string()))
|
||||
}
|
||||
|
||||
fn parse_status(headers: &str) -> u16 {
|
||||
headers.lines().next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find_header_end(data: &[u8]) -> Option<usize> {
|
||||
data.windows(4).position(|w| w == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn extract_header<'a>(headers: &'a str, name: &str) -> Option<String> {
|
||||
for line in headers.lines() {
|
||||
if line.len() > name.len() + 2
|
||||
&& line[..name.len()].eq_ignore_ascii_case(name)
|
||||
&& line.as_bytes()[name.len()] == b':'
|
||||
{
|
||||
return Some(line[name.len() + 1..].trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_zip(data: &[u8]) -> Result<String> {
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).map_err(|_| Error::KeydbParse)?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|_| Error::KeydbParse)?;
|
||||
if file.name().ends_with(".cfg") || file.name().ends_with(".CFG") {
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).map_err(|_| Error::KeydbParse)?;
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::KeydbInvalid)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
|
||||
//!
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
let stream_infos = parse_stream_infos(sp_text);
|
||||
if stream_infos.is_empty() { return None; }
|
||||
|
||||
// Stream number mapping from playbackconfig.xml
|
||||
let mut stream_map: HashMap<String, u16> = HashMap::new();
|
||||
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
|
||||
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
|
||||
parse_playback_config(pc_text, &mut stream_map);
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut audio_idx: u16 = 1;
|
||||
let mut sub_idx: u16 = 1;
|
||||
|
||||
for info in &stream_infos {
|
||||
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
|
||||
match info.stream_type {
|
||||
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
|
||||
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
|
||||
}
|
||||
});
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type: info.stream_type,
|
||||
language: info.language.clone(),
|
||||
name: String::new(),
|
||||
purpose: info.purpose,
|
||||
qualifier: info.qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: info.variant.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
id: String,
|
||||
stream_type: StreamLabelType,
|
||||
language: String,
|
||||
variant: String,
|
||||
purpose: LabelPurpose,
|
||||
qualifier: LabelQualifier,
|
||||
}
|
||||
|
||||
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
|
||||
let mut infos = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < xml.len() {
|
||||
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Audio)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Subtitle)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let end_tag = match stream_type {
|
||||
StreamLabelType::Audio => "</AudioStreamInfos>",
|
||||
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
|
||||
};
|
||||
|
||||
let block_end = match xml[tag..].find(end_tag) {
|
||||
Some(p) => tag + p + end_tag.len(),
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block = &xml[tag..block_end];
|
||||
let id = extract_tag(block, "ID").unwrap_or_default();
|
||||
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
|
||||
let content = extract_tag(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
|
||||
|
||||
let (language, variant) = if lang_id.contains('_') {
|
||||
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
||||
(parts[0].to_lowercase(), parts[1].to_string())
|
||||
} else {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let purpose = match content.as_str() {
|
||||
"COMMENTARY" => LabelPurpose::Commentary,
|
||||
_ => LabelPurpose::Normal,
|
||||
};
|
||||
|
||||
let qualifier = match qualifier_str.as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
|
||||
pos = block_end;
|
||||
}
|
||||
infos
|
||||
}
|
||||
|
||||
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
let mut pos = 0;
|
||||
while pos < xml.len() {
|
||||
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
|
||||
Some(p + pos)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
|
||||
Some(p + pos)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block_end = xml[tag_start..].find("</AudioStreams>")
|
||||
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
|
||||
.map(|p| tag_start + p + 20)
|
||||
.unwrap_or(xml.len());
|
||||
|
||||
let block = &xml[tag_start..block_end];
|
||||
|
||||
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{}>", tag);
|
||||
let close = format!("</{}>", tag);
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt`
|
||||
//!
|
||||
//! Two sub-formats from the same framework. A disc may have one or both.
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "menu_base.prop")
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(session, udf);
|
||||
|
||||
// Try menu_base.prop (stream numbers + key names)
|
||||
let mb_labels = parse_menu_base(session, udf);
|
||||
|
||||
// If we have both, merge: language_streams for structure, menu_base for names
|
||||
match (ls_labels, mb_labels) {
|
||||
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
|
||||
(Some(ls), None) => Some(ls),
|
||||
(None, Some(mb)) => Some(mb),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
// language_streams has better type/purpose data, menu_base has button names
|
||||
// Match by stream number + type, take name from menu_base
|
||||
let mut result = ls;
|
||||
for label in &mut result {
|
||||
if let Some(mb_match) = mb.iter().find(|m|
|
||||
m.stream_type == label.stream_type && m.stream_number == label.stream_number
|
||||
) {
|
||||
if label.name.is_empty() && !mb_match.name.is_empty() {
|
||||
label.name = mb_match.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() < 4 { continue; }
|
||||
|
||||
let type_str = parts[1];
|
||||
let stream_num: u16 = match parts[2].parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let language = parts[3].to_string();
|
||||
let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() };
|
||||
|
||||
let (stream_type, purpose, qualifier) = match type_str {
|
||||
"audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None),
|
||||
"subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced),
|
||||
"subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
|
||||
"subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None),
|
||||
"subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced),
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Classify variant code
|
||||
let mut codec_hint = String::new();
|
||||
let mut variant_code = String::new();
|
||||
let mut final_purpose = purpose;
|
||||
|
||||
if !variant.is_empty() {
|
||||
match variant.as_str() {
|
||||
// Codec variants — use shared label vocab
|
||||
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
|
||||
codec_hint = vocab::codec(&variant).to_string();
|
||||
}
|
||||
// Purpose variants
|
||||
"eda" => final_purpose = LabelPurpose::Descriptive,
|
||||
// Dialect variants — pass through raw code from disc
|
||||
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
|
||||
variant_code = variant.clone();
|
||||
}
|
||||
// Unknown — store as-is in codec_hint
|
||||
_ => codec_hint = variant.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name: String::new(),
|
||||
purpose: final_purpose,
|
||||
qualifier,
|
||||
codec_hint,
|
||||
variant: variant_code,
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Parse key=value, group by prefix
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
let eq_pos = match line.find('=') {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
|
||||
if let Some(dot_pos) = full_key.rfind('.') {
|
||||
let prefix = full_key[..dot_pos].to_string();
|
||||
let key = full_key[dot_pos + 1..].to_string();
|
||||
entries.entry(prefix).or_default().insert(key, value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
for (prefix, props) in &entries {
|
||||
// Audio: has "streamNumber" or "audioStream" and audio-related class
|
||||
let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton"))
|
||||
|| prefix.starts_with("subtitle_");
|
||||
|
||||
let stream_num_str = props.get("streamNumber")
|
||||
.or_else(|| props.get("audioStream"))
|
||||
.or_else(|| props.get("subtitleStream"));
|
||||
|
||||
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if !is_audio && !is_subtitle { continue; }
|
||||
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if is_subtitle && name_lower.contains("sdh") {
|
||||
LabelQualifier::Sdh
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle };
|
||||
|
||||
// Try to extract language from audioLanguage/subtitleLanguage prop
|
||||
let language = props.get("audioLanguage")
|
||||
.or_else(|| props.get("subtitleLanguage"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
Some(labels)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Stream label extraction from BD-J disc files.
|
||||
//!
|
||||
//! Each parser module represents one BD-J authoring framework.
|
||||
//! To add a new format:
|
||||
//! 1. Create `src/labels/myformat.rs`
|
||||
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
|
||||
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
|
||||
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
|
||||
|
||||
mod paramount;
|
||||
mod criterion;
|
||||
mod pixelogic;
|
||||
mod ctrm;
|
||||
pub mod vocab;
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use crate::disc::{Title, Stream};
|
||||
|
||||
/// A stream label extracted from disc config files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamLabel {
|
||||
/// STN index (1-based)
|
||||
pub stream_number: u16,
|
||||
/// Audio or Subtitle
|
||||
pub stream_type: StreamLabelType,
|
||||
/// ISO 639-2 language code
|
||||
pub language: String,
|
||||
/// Display name (e.g. "Commentary", "Descriptive Audio")
|
||||
pub name: String,
|
||||
/// Stream purpose
|
||||
pub purpose: LabelPurpose,
|
||||
/// Additional qualifier
|
||||
pub qualifier: LabelQualifier,
|
||||
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
|
||||
pub codec_hint: String,
|
||||
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
|
||||
pub variant: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum StreamLabelType {
|
||||
Audio,
|
||||
Subtitle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelPurpose {
|
||||
Normal,
|
||||
Commentary,
|
||||
Descriptive,
|
||||
Score,
|
||||
Ime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LabelQualifier {
|
||||
None,
|
||||
Sdh,
|
||||
DescriptiveService,
|
||||
Forced,
|
||||
}
|
||||
|
||||
// ── Parser registry ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each entry: (name, detect_fn, parse_fn)
|
||||
// Order = priority. First match wins. Highest quality output first.
|
||||
|
||||
type DetectFn = fn(&UdfFs) -> bool;
|
||||
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
|
||||
|
||||
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
|
||||
("paramount", paramount::detect, paramount::parse),
|
||||
("criterion", criterion::detect, criterion::parse),
|
||||
("pixelogic", pixelogic::detect, pixelogic::parse),
|
||||
("ctrm", ctrm::detect, ctrm::parse),
|
||||
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
|
||||
];
|
||||
|
||||
/// Search disc for config files, extract labels, apply to streams.
|
||||
/// This is 100% optional — if anything fails, streams are untouched.
|
||||
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
|
||||
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
extract(session, udf)
|
||||
})).unwrap_or_default();
|
||||
if labels.is_empty() { return; }
|
||||
|
||||
for title in titles.iter_mut() {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for stream in &mut title.streams {
|
||||
match stream {
|
||||
Stream::Audio(a) => {
|
||||
audio_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
|
||||
) {
|
||||
let mut parts = Vec::new();
|
||||
match label.purpose {
|
||||
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
|
||||
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
|
||||
LabelPurpose::Score => parts.push("Score".to_string()),
|
||||
LabelPurpose::Ime => parts.push("IME".to_string()),
|
||||
LabelPurpose::Normal => {}
|
||||
}
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
} else if !label.name.is_empty() {
|
||||
a.label = label.name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Stream::Subtitle(s) => {
|
||||
sub_idx += 1;
|
||||
if let Some(label) = labels.iter().find(|l|
|
||||
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
|
||||
) {
|
||||
if label.qualifier == LabelQualifier::Forced {
|
||||
s.forced = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
|
||||
for (_name, detect, parse) in PARSERS {
|
||||
if detect(udf) {
|
||||
if let Some(labels) = parse(session, udf) {
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
// ── Shared helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a file exists in any BDMV/JAR subdirectory.
|
||||
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
|
||||
find_jar_file(udf, filename).is_some()
|
||||
}
|
||||
|
||||
/// Find a file in any BDMV/JAR subdirectory, return its path.
|
||||
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
|
||||
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
||||
for entry in &jar_dir.entries {
|
||||
if entry.is_dir {
|
||||
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
|
||||
// Check if file exists in this subdirectory
|
||||
for child in &entry.entries {
|
||||
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a file from any BDMV/JAR subdirectory by filename.
|
||||
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
|
||||
let path = find_jar_file(udf, filename)?;
|
||||
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Paramount/onQ — `playlists.xml`
|
||||
//!
|
||||
//! Richest structured format. Complete language lists with forced flags
|
||||
//! and commentary indices per playlist, all in XML attributes.
|
||||
//!
|
||||
//! ```xml
|
||||
//! <playlist name="Feature" id="00222"
|
||||
//! aud="eng,deu,spa,spa,fra"
|
||||
//! sub="eng,eng,zho,ces,dan"
|
||||
//! forced_sub="0,0,0,1,0"
|
||||
//! aud_com1_idx="10"
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
// Find the feature playlist — longest duration or name="Feature"
|
||||
let feature = find_feature_playlist(text)?;
|
||||
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Parse audio streams
|
||||
if let Some(aud) = extract_attr(&feature, "aud") {
|
||||
let com_idx = extract_attr(&feature, "aud_com1_idx")
|
||||
.and_then(|s| s.parse::<usize>().ok());
|
||||
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
let purpose = if com_idx == Some(i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Audio,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = extract_attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
for (i, lang) in sub.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
if lang.is_empty() { continue; }
|
||||
|
||||
let purpose = if com_indices.contains(&i) {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let qualifier = if forced.get(i).copied().unwrap_or(false) {
|
||||
LabelQualifier::Forced
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number: (i + 1) as u16,
|
||||
stream_type: StreamLabelType::Subtitle,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
|
||||
let mut pos = 0;
|
||||
while let Some(start) = xml[pos..].find("<playlist ") {
|
||||
let abs_start = pos + start;
|
||||
let end = match xml[abs_start..].find("/>") {
|
||||
Some(p) => abs_start + p + 2,
|
||||
None => break,
|
||||
};
|
||||
let element = &xml[abs_start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly
|
||||
if let Some(name) = extract_attr(element, "name") {
|
||||
if name.eq_ignore_ascii_case("Feature") {
|
||||
return Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams
|
||||
if let Some(aud) = extract_attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
best = Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pos = end;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> {
|
||||
let needle = format!("{}=\"", name);
|
||||
let start = element.find(&needle)? + needle.len();
|
||||
let end = element[start..].find('"')? + start;
|
||||
Some(element[start..end].to_string())
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Pixelogic — `bluray_project.bin`
|
||||
//!
|
||||
//! Binary file with embedded UTF-8 token strings in STN order per
|
||||
//! playlist section. Most common format (5/10 test discs).
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use crate::drive::DriveSession;
|
||||
use crate::udf::UdfFs;
|
||||
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
/// Known region tokens
|
||||
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut in_feature = false;
|
||||
let mut audio_num: u16 = 0;
|
||||
let mut sub_num: u16 = 0;
|
||||
|
||||
for s in &strings {
|
||||
// Detect feature section start
|
||||
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
|
||||
if in_feature { break; }
|
||||
in_feature = true;
|
||||
audio_num = 0;
|
||||
sub_num = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect section end
|
||||
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
|
||||
break;
|
||||
}
|
||||
|
||||
if !in_feature { continue; }
|
||||
|
||||
if let Some(label) = parse_token(s) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_num += 1;
|
||||
labels.push(StreamLabel { stream_number: audio_num, ..label });
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_num += 1;
|
||||
labels.push(StreamLabel { stream_number: sub_num, ..label });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() { return None; }
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let lang = parts[0];
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut codec = String::new();
|
||||
let mut purpose = LabelPurpose::Normal;
|
||||
let mut qualifier = LabelQualifier::None;
|
||||
let mut variant = String::new();
|
||||
let mut is_subtitle = false;
|
||||
let mut is_audio = false;
|
||||
|
||||
for &part in &parts[1..] {
|
||||
if part.is_empty() { continue; }
|
||||
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
|
||||
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
|
||||
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
|
||||
else if part == "ADLG" { is_audio = true; }
|
||||
else if part == "ATRI" { is_audio = true; }
|
||||
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
|
||||
else if part == "SDLG" { is_subtitle = true; }
|
||||
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
|
||||
else if part == "STRI" { is_subtitle = true; }
|
||||
else if part == "TXT" { is_subtitle = true; }
|
||||
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
|
||||
else if REGIONS.contains(&part) { variant = part.to_string(); }
|
||||
else if part.starts_with("PGStream") { is_subtitle = true; }
|
||||
else { return None; }
|
||||
}
|
||||
|
||||
if !is_audio && !is_subtitle { return None; }
|
||||
|
||||
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
|
||||
|
||||
Some(StreamLabel {
|
||||
stream_number: 0,
|
||||
stream_type,
|
||||
language: lang.to_string(),
|
||||
name: String::new(),
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: codec,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for &b in data {
|
||||
if b >= 0x20 && b < 0x7f {
|
||||
current.push(b as char);
|
||||
} else {
|
||||
if current.len() > 3 {
|
||||
strings.push(current.clone());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() > 3 {
|
||||
strings.push(current);
|
||||
}
|
||||
strings
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared label vocabulary — values we are 100% confident about.
|
||||
//!
|
||||
//! Labels come from BD-J authoring tool files (bluray_project.bin,
|
||||
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
|
||||
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
|
||||
//!
|
||||
//! Rules:
|
||||
//! - Only map values we are 100% certain about (published codec names).
|
||||
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
|
||||
//! - The app/CLI handles display text, not the lib.
|
||||
|
||||
/// Map a codec identifier found in label data to its display name.
|
||||
///
|
||||
/// These are well-known codec identifiers used across multiple BD-J
|
||||
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
|
||||
/// separately in mpls.rs.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
"MLP" => "TrueHD",
|
||||
"AC3" | "AC" => "Dolby Digital",
|
||||
"DTS" => "DTS",
|
||||
"DDL" => "Dolby Digital Plus",
|
||||
"WAV" => "PCM",
|
||||
"atmos" => "Dolby Atmos",
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -6,10 +6,11 @@
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions};
|
||||
//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
//! let device = find_drive().expect("no optical drive found");
|
||||
//! let mut session = DriveSession::open(Path::new(&device)).unwrap();
|
||||
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! for title in &disc.titles {
|
||||
@@ -77,15 +78,17 @@ pub mod udf;
|
||||
pub mod mpls;
|
||||
pub mod clpi;
|
||||
pub mod disc;
|
||||
pub mod jar;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
pub mod keydb;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use drive::DriveSession;
|
||||
pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::{DriveProfile, Chipset};
|
||||
pub use platform::{Platform, DriveStatus};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace,
|
||||
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace,
|
||||
Extent, ContentReader, AacsState, KeySource, ScanOptions};
|
||||
|
||||
+139
-148
@@ -35,22 +35,28 @@ pub struct PlayItem {
|
||||
/// A stream entry from the STN table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamEntry {
|
||||
/// Stream category: 1=primary video, 2=primary audio, 3=PG subtitle, 4=IG
|
||||
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
|
||||
pub stream_type: u8,
|
||||
/// MPEG-TS PID
|
||||
pub pid: u16,
|
||||
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
|
||||
pub coding_type: u8,
|
||||
/// Video format (1=480i, 6=1080p, 8=2160p, etc.)
|
||||
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
|
||||
pub video_format: u8,
|
||||
/// Video frame rate (1=23.976, 3=25, 4=29.97, etc.)
|
||||
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
|
||||
pub video_rate: u8,
|
||||
/// Audio format (3=stereo, 6=5.1, 12=7.1, etc.)
|
||||
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
|
||||
pub audio_rate: u8,
|
||||
/// ISO 639-2 language code (e.g. "eng")
|
||||
pub language: String,
|
||||
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
|
||||
pub dynamic_range: u8,
|
||||
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
|
||||
pub color_space: u8,
|
||||
/// Whether this is a secondary stream (commentary, PiP, DV EL)
|
||||
pub secondary: bool,
|
||||
}
|
||||
|
||||
/// Parse an MPLS file from raw bytes.
|
||||
@@ -58,40 +64,31 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
if data.len() < 40 {
|
||||
return Err(Error::DiscError { detail: "MPLS too short".into() });
|
||||
}
|
||||
|
||||
// Header: "MPLS" + version (4 bytes ASCII)
|
||||
if &data[0..4] != b"MPLS" {
|
||||
return Err(Error::DiscError { detail: "not an MPLS file".into() });
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
// Offsets table at bytes 8-19
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
|
||||
if playlist_start + 10 > data.len() {
|
||||
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
|
||||
}
|
||||
|
||||
// PlayList section
|
||||
let pl = &data[playlist_start..];
|
||||
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
|
||||
|
||||
let mut play_items = Vec::with_capacity(num_play_items);
|
||||
let mut streams = Vec::new();
|
||||
let mut pos = 10; // start of first play item
|
||||
let mut pos = 10;
|
||||
|
||||
for item_idx in 0..num_play_items {
|
||||
if playlist_start + pos + 2 > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
if pos + 2 > pl.len() { break; }
|
||||
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
|
||||
if playlist_start + pos + item_length + 2 > data.len() {
|
||||
break;
|
||||
}
|
||||
if pos + 2 + item_length > pl.len() { break; }
|
||||
|
||||
let item = &pl[pos + 2..pos + 2 + item_length];
|
||||
if item.len() < 20 { break; }
|
||||
if item.len() < 20 { pos += 2 + item_length; continue; }
|
||||
|
||||
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
|
||||
let connection_condition = item[9] & 0x0F;
|
||||
@@ -99,83 +96,91 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
|
||||
|
||||
// Parse STN table from the first play item
|
||||
if item_idx == 0 && item.len() > 32 {
|
||||
// UO mask is 8 bytes (64 bits) at offset 20
|
||||
// STN table starts after: 2 bytes length + 2 bytes reserved + UO mask (8 bytes)
|
||||
// STN offset within play_item = 20 (UO start)
|
||||
// Actually: offset 20 = UO_mask_table (8 bytes)
|
||||
// offset 28 = random_access_flag + ... (2 bytes)
|
||||
// offset 30 = still_mode (1 byte) + still_time (2 bytes if still)
|
||||
// Then STN table
|
||||
// The STN table position varies. Let's find it by looking for the STN length field.
|
||||
// PlayItem layout after out_time:
|
||||
// [20:28] UO_mask_table (8 bytes)
|
||||
// [28] misc flags (1 byte)
|
||||
// [29] still_mode (1 byte)
|
||||
// [30:32] still_time (2 bytes)
|
||||
// [32:] STN_table
|
||||
const STN_OFFSET: usize = 32;
|
||||
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
|
||||
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
|
||||
let n_video = item[STN_OFFSET + 4] as usize;
|
||||
let n_audio = item[STN_OFFSET + 5] as usize;
|
||||
let n_pg = item[STN_OFFSET + 6] as usize;
|
||||
let n_ig = item[STN_OFFSET + 7] as usize;
|
||||
let n_sec_audio = item[STN_OFFSET + 8] as usize;
|
||||
let n_sec_video = item[STN_OFFSET + 9] as usize;
|
||||
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_dv = item[STN_OFFSET + 11] as usize;
|
||||
|
||||
// Per BD spec: play_item structure after out_time:
|
||||
// [20..28] UO_mask_table (8 bytes)
|
||||
// [28] misc flags (1 byte)
|
||||
// [29] still_mode (1 byte)
|
||||
// [30..32] still_time (2 bytes) if still_mode != 0
|
||||
// Then STN_table
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
|
||||
let still_mode = if item.len() > 29 { item[29] } else { 0 };
|
||||
let stn_offset = if still_mode != 0 { 32 } else { 32 };
|
||||
// Actually it's always 32 based on our previous work (UO mask = 8 bytes, not 64)
|
||||
|
||||
if item.len() > stn_offset + 6 {
|
||||
let stn = &item[stn_offset..];
|
||||
let _stn_length = u16::from_be_bytes([stn[0], stn[1]]) as usize;
|
||||
if stn.len() > 6 {
|
||||
// reserved 2 bytes at [2..4]
|
||||
let n_video = stn[4] as usize;
|
||||
let n_audio = stn[5] as usize;
|
||||
let n_pg = if stn.len() > 6 { stn[6] as usize } else { 0 };
|
||||
let n_ig = if stn.len() > 7 { stn[7] as usize } else { 0 };
|
||||
|
||||
// Parse stream entries starting at offset 8
|
||||
// But there's another 2 bytes reserved before entries
|
||||
let mut stn_pos = 8;
|
||||
// Possible 2 more reserved bytes
|
||||
// Let's skip and parse entries
|
||||
|
||||
// Primary video streams
|
||||
for _ in 0..n_video {
|
||||
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 1) {
|
||||
streams.push(entry);
|
||||
stn_pos += len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Primary audio streams
|
||||
for _ in 0..n_audio {
|
||||
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 2) {
|
||||
streams.push(entry);
|
||||
stn_pos += len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// PG (subtitle) streams
|
||||
for _ in 0..n_pg {
|
||||
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 3) {
|
||||
streams.push(entry);
|
||||
stn_pos += len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// IG streams
|
||||
for _ in 0..n_ig {
|
||||
if let Some((entry, len)) = parse_stream_entry(stn, stn_pos, 4) {
|
||||
streams.push(entry);
|
||||
stn_pos += len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Primary video
|
||||
for _ in 0..n_video {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Primary audio
|
||||
for _ in 0..n_audio {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// PG subtitles
|
||||
for _ in 0..n_pg {
|
||||
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// IG (skip but advance)
|
||||
for _ in 0..n_ig {
|
||||
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary audio
|
||||
for _ in 0..n_sec_audio {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
|
||||
entry.stream_type = 5;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes: num_refs(1) + reserved(1) + refs + padding
|
||||
if next < item.len() {
|
||||
let n_refs = item[next] as usize;
|
||||
spos = next + 2 + n_refs + (n_refs % 2);
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary video (PiP)
|
||||
for _ in 0..n_sec_video {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 6;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip extra ref bytes (audio refs + PG refs)
|
||||
if next + 2 < item.len() {
|
||||
let n_arefs = item[next] as usize;
|
||||
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
|
||||
if after_arefs < item.len() {
|
||||
let n_prefs = item[after_arefs] as usize;
|
||||
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
|
||||
} else { spos = after_arefs; }
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Dolby Vision enhancement layer
|
||||
for _ in 0..n_dv {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
entry.stream_type = 7;
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
spos = next;
|
||||
} else { break; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,87 +202,70 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
}
|
||||
|
||||
/// Parse one stream entry from the STN table.
|
||||
/// Returns (StreamEntry, bytes consumed) or None.
|
||||
fn parse_stream_entry(stn: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
|
||||
if pos + 2 > stn.len() { return None; }
|
||||
/// Returns (StreamEntry, next position) or None.
|
||||
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
|
||||
if pos + 2 > item.len() { return None; }
|
||||
|
||||
// Stream entry format:
|
||||
// [0] length of stream entry (1 byte)
|
||||
// [1] stream_type (1=PlayItem, 2=SubPath, 3=InMux)
|
||||
// Then stream_pid reference (varies by stream_type)
|
||||
// Then stream attributes
|
||||
// Stream entry: length(1) + data
|
||||
let se_len = item[pos] as usize;
|
||||
let se_end = pos + 1 + se_len;
|
||||
if se_end > item.len() { return None; }
|
||||
|
||||
let entry_len = stn[pos] as usize;
|
||||
if entry_len < 4 || pos + 1 + entry_len > stn.len() { return None; }
|
||||
|
||||
let entry = &stn[pos + 1..pos + 1 + entry_len];
|
||||
|
||||
// Stream entry: type(1) + ref_type-dependent PID extraction
|
||||
// For type 1 (PlayItem stream): [0]=type, [1..3]=ref_to_stream_PID_of_playItem
|
||||
// ref format: [0] = subpath/playitem ref type, [1..2] = PID (big-endian u16)
|
||||
let pid = if entry.len() >= 3 {
|
||||
u16::from_be_bytes([entry[1], entry[2]])
|
||||
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
|
||||
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Stream attributes follow after the PID reference
|
||||
// Attributes block: length(1) + coding_type(1) + format-specific data + language(3)
|
||||
let attr_start = pos + 1 + entry_len;
|
||||
if attr_start + 2 > stn.len() { return None; }
|
||||
// Stream attributes: length(1) + coding_type(1) + format-specific data
|
||||
if se_end + 2 > item.len() { return None; }
|
||||
let sa_len = item[se_end] as usize;
|
||||
let sa_end = se_end + 1 + sa_len;
|
||||
if sa_end > item.len() || sa_len < 1 { return None; }
|
||||
|
||||
let attr_len = stn[attr_start] as usize;
|
||||
if attr_len < 4 || attr_start + 1 + attr_len > stn.len() {
|
||||
return Some((StreamEntry {
|
||||
stream_type, pid, coding_type: 0, video_format: 0, video_rate: 0,
|
||||
audio_format: 0, audio_rate: 0, language: String::new(),
|
||||
}, 1 + entry_len + 1 + attr_len.max(1)));
|
||||
}
|
||||
|
||||
let attr = &stn[attr_start + 1..attr_start + 1 + attr_len];
|
||||
let coding_type = attr[0];
|
||||
let sa = &item[se_end + 1..se_end + 1 + sa_len];
|
||||
let coding_type = sa[0];
|
||||
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut audio_format = 0u8;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut dynamic_range = 0u8;
|
||||
let mut color_space_val = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match stream_type {
|
||||
1 => {
|
||||
// Video: coding_type(1) + format_and_rate(1) + ...
|
||||
if attr.len() >= 2 {
|
||||
video_format = (attr[1] >> 4) & 0x0F;
|
||||
video_rate = attr[1] & 0x0F;
|
||||
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
|
||||
if sa.len() >= 2 {
|
||||
video_format = (sa[1] >> 4) & 0x0F;
|
||||
video_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if coding_type == 0x24 && sa.len() > 2 {
|
||||
dynamic_range = (sa[2] >> 4) & 0x0F;
|
||||
color_space_val = sa[2] & 0x0F;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Audio: coding_type(1) + format_and_rate(1) + language(3)
|
||||
if attr.len() >= 2 {
|
||||
audio_format = (attr[1] >> 4) & 0x0F;
|
||||
audio_rate = attr[1] & 0x0F;
|
||||
// Audio: coding_type(1) + format_rate(1) + language(3)
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if attr.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&attr[2..5]).to_string();
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// PG subtitle: coding_type(1) + language(3)
|
||||
if attr.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&attr[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
// IG: coding_type(1) + language(3)
|
||||
if attr.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&attr[1..4]).to_string();
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let total_consumed = 1 + entry_len + 1 + attr_len;
|
||||
|
||||
Some((StreamEntry {
|
||||
stream_type,
|
||||
pid,
|
||||
@@ -287,5 +275,8 @@ fn parse_stream_entry(stn: &[u8], pos: usize, stream_type: u8) -> Option<(Stream
|
||||
audio_format,
|
||||
audio_rate,
|
||||
language,
|
||||
}, total_consumed))
|
||||
dynamic_range,
|
||||
color_space: color_space_val,
|
||||
secondary: false,
|
||||
}, sa_end))
|
||||
}
|
||||
|
||||
+44
-29
@@ -1,61 +1,76 @@
|
||||
//! Platform-specific implementations of raw disc access commands.
|
||||
//!
|
||||
//! Each chipset family (MT1959, Pioneer) implements the Platform trait.
|
||||
//! accessed via SCSI READ BUFFER with platform-specific mode and buffer ID.
|
||||
//! The trait methods correspond to the 10 command handlers in the per-drive
|
||||
//!
|
||||
//! x86 dispatch order (proven from code + hardware traces):
|
||||
//! 1. unlock() — try activate raw mode
|
||||
//! 2. load_firmware() — if unlock fails, write ld_microcode then retry
|
||||
//! 3. calibrate() — probe disc zones, build speed table, triple SET_CD_SPEED
|
||||
//! 5. read_register() — read hardware registers A and B (mid-rip, retried)
|
||||
//! 6. status() — query feature flags
|
||||
//! 7. probe() — parameterized register read
|
||||
//! 8. set_read_speed()— per-zone SET_CD_SPEED during content reads
|
||||
//!
|
||||
//! The full init sequence is:
|
||||
//! unlock → [load_firmware if fail] × 6 → calibrate × 6 →
|
||||
//! drive_info → registers × 5 → status × 6 → probe
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
/// Platform trait — raw disc access commands implemented per chipset.
|
||||
///
|
||||
/// Command handlers accessed via READ BUFFER:
|
||||
pub trait Platform {
|
||||
///
|
||||
/// Sends the platform-specific READ BUFFER CDB and verifies
|
||||
/// the response signature bytes.
|
||||
/// Sends READ_BUFFER with drive-specific mode/buf_id.
|
||||
/// Checks response against drive_signature and mode_active_magic ("MMkv").
|
||||
/// Returns Ok if mode already active (warm), Err if firmware needed (cold).
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Performs a primary READ BUFFER for the configuration data,
|
||||
/// followed by a secondary 4-byte status read.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>>;
|
||||
|
||||
/// Handlers 2-3: Read hardware register.
|
||||
///
|
||||
/// `index` selects which register offset from the profile to use.
|
||||
/// Returns 16 bytes of register data extracted from a 36-byte response.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]>;
|
||||
/// Sends WRITE_BUFFER mode=6 with ld_microcode (1888 bytes).
|
||||
/// Verifies with READ_BUFFER buf=0x45 (expects response == 2).
|
||||
/// Then calls unlock() twice to activate mode.
|
||||
/// Only called when unlock() fails (cold boot, firmware not in drive RAM).
|
||||
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Probes the disc surface via READ BUFFER sub-commands to build
|
||||
/// a 64-entry speed lookup table for optimal read performance.
|
||||
/// Issues SET CD SPEED at maximum after calibration completes.
|
||||
/// Sends pre-built hardware_register_a_cdb. Returns 16 bytes from
|
||||
/// the 36-byte response at offset [4:20].
|
||||
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Sends pre-built hardware_register_b_cdb. Returns 16 bytes from
|
||||
/// the 36-byte response at offset [4:20].
|
||||
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Probes disc zones via READ_BUFFER sub_cmd=0x14, builds speed table,
|
||||
/// then commits with triple SET_CD_SPEED (max → nominal → max).
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Periodic command to maintain the raw access session.
|
||||
fn keepalive(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Verifies the response signature and returns 16 bytes of
|
||||
/// feature/status data.
|
||||
/// Returns 16 bytes of feature data via READ_BUFFER sub_cmd=0x13.
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus>;
|
||||
|
||||
///
|
||||
/// Sends a READ BUFFER command with dynamic sub-command, address,
|
||||
/// and length. Used for disc structure reads and feature queries.
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>>;
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED,
|
||||
/// then performs a READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(&mut self, scsi: &mut dyn ScsiTransport, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
|
||||
/// Looks up LBA in speed_zone_table, sends SET_CD_SPEED.
|
||||
/// Called by x86 before each zone change during content reads.
|
||||
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()>;
|
||||
|
||||
fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Check if raw disc access mode is currently enabled.
|
||||
/// Full init sequence — matches x86 dispatch order.
|
||||
///
|
||||
/// unlock → [load_firmware if fail] × 6 → calibrate × 6
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Check if raw disc access mode is currently active.
|
||||
fn is_unlocked(&self) -> bool;
|
||||
}
|
||||
|
||||
|
||||
+473
-199
@@ -1,12 +1,11 @@
|
||||
//! MT1959 platform implementation — covers all LG/ASUS MediaTek drives.
|
||||
//!
|
||||
//! Two variants share this code:
|
||||
//! MT1959-A: mode=0x01, buffer_id=0x44 (handlers 0-9)
|
||||
//! MT1959-A: mode=0x01, buffer_id=0x44 (all 10 handlers)
|
||||
//! MT1959-B: mode=0x02, buffer_id=0x77 (handlers 4-9, 0-3 are no-ops)
|
||||
//!
|
||||
//! The logic is identical between A and B — only the SCSI READ BUFFER
|
||||
//! mode and buffer ID differ. Per-drive data (signature, register offsets)
|
||||
//! comes from the profile.
|
||||
//! The handler logic is identical across all 206 drives — only the
|
||||
//! profile data differs (signature, register CDBs, microcode, etc).
|
||||
//!
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
@@ -19,8 +18,15 @@ pub struct Mt1959 {
|
||||
mode: u8,
|
||||
buffer_id: u8,
|
||||
unlocked: bool,
|
||||
/// Speed table: 64 × u16, built by calibrate().
|
||||
/// Stores zone boundary addresses from disc surface probes.
|
||||
speed_table: [u16; 64],
|
||||
/// Total disc sectors — from READ CAPACITY.
|
||||
disc_sectors: u32,
|
||||
calibrated: bool,
|
||||
/// 4 config bytes stored by calibrate() from initial probe response.
|
||||
/// [0]=speed_mult, [1]=data_byte, [2]=data_byte, [3]=last_speed
|
||||
calibration_config: [u8; 4],
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
@@ -33,162 +39,185 @@ impl Mt1959 {
|
||||
buffer_id,
|
||||
unlocked: false,
|
||||
speed_table: [0u16; 64],
|
||||
disc_sectors: 0,
|
||||
calibrated: false,
|
||||
calibration_config: [0u8; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB for this platform's mode and buffer ID.
|
||||
fn read_buffer_cdb(&self, offset: u32, length: u32) -> [u8; 10] {
|
||||
scsi::build_read_buffer(self.mode, self.buffer_id, offset, length)
|
||||
}
|
||||
|
||||
/// Build a READ BUFFER CDB with a sub-command byte in CDB[3].
|
||||
/// Build READ_BUFFER with sub_cmd in CDB[3] and address in CDB[4:6].
|
||||
fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
length,
|
||||
0x00,
|
||||
0x3C, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 8) as u8, address as u8,
|
||||
0x00, 0x00, length, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
///
|
||||
/// 1. Send READ BUFFER(mode, buffer_id, offset=0, length=64)
|
||||
/// 2. Check response[0:4] matches the profile signature
|
||||
/// 3. Check response[12:16] matches the verification bytes (0x4D4D6B76)
|
||||
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 64]> {
|
||||
let cdb = self.read_buffer_cdb(0, 64);
|
||||
let mut response = [0u8; 64];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Check signature at response[0:4]
|
||||
let got_sig: [u8; 4] = response[0..4].try_into().unwrap();
|
||||
if got_sig != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: got_sig,
|
||||
});
|
||||
/// Calls dynamic_read_buffer, validates response size matches expected.
|
||||
/// Returns Ok(bytes_transferred) or Err.
|
||||
fn read_buffer_probe(
|
||||
&self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
|
||||
) -> Result<usize> {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
|
||||
// Check verification bytes at response[12:16]
|
||||
if &response[12..16] != self.profile.verify.as_slice() {
|
||||
return Err(Error::UnlockFailed { detail: format!(
|
||||
"verify mismatch at [12:16]: {:02x}{:02x}{:02x}{:02x}",
|
||||
response[12], response[13], response[14], response[15]
|
||||
) });
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Ensure raw disc access is active, re-enabling if needed.
|
||||
fn ensure_unlocked(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked {
|
||||
self.do_unlock(scsi)?;
|
||||
}
|
||||
/// Sends SET_CD_SPEED from CDB template at 0x9A78: BB 00 FF FF FF FF...
|
||||
fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pre-operation validation with retry.
|
||||
///
|
||||
/// Sends a short READ BUFFER probe, retries up to 5 times to confirm
|
||||
/// the drive is still responding to commands.
|
||||
fn validate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = self.read_buffer_cdb(0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
opcode: 0x3C,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up optimal read speed for a given LBA from the calibration table.
|
||||
fn lookup_speed(&self, lba: u32) -> u16 {
|
||||
if !self.calibrated {
|
||||
return 0;
|
||||
}
|
||||
let mut best_speed = 0u16;
|
||||
let mut best_diff = u32::MAX;
|
||||
for &entry in &self.speed_table {
|
||||
if entry == 0 {
|
||||
continue;
|
||||
}
|
||||
let entry_lba = entry as u32;
|
||||
let diff = if lba > entry_lba { lba - entry_lba } else { entry_lba - lba };
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best_speed = entry;
|
||||
}
|
||||
}
|
||||
best_speed
|
||||
}
|
||||
|
||||
/// Send SET CD SPEED command.
|
||||
/// Build and send a custom SET_CD_SPEED with a specific speed value.
|
||||
fn set_cd_speed(&self, scsi: &mut dyn ScsiTransport, speed: u16) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(speed);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Core unlock function. Returns the full response on success.
|
||||
///
|
||||
/// r3 = unlock_init_value (1)
|
||||
/// sp[0x1C] = r3
|
||||
/// r3 += unlock_response_size_minus_init (0x3F)
|
||||
/// sp[0] = r3 (= 64 = response size)
|
||||
/// scsi_cmd_wrapper(result, 0x0A, unlock_CDB, response)
|
||||
/// check response[0:4] == drive_signature (LE u32)
|
||||
/// check response[12:16] == "MMkv" (0x766B4D4D LE)
|
||||
/// check response[16:20] version range
|
||||
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let response_size = self.profile.unlock_init_value as u32
|
||||
+ self.profile.unlock_response_size_minus_init as u32;
|
||||
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
0x00, 0x00, 0x00,
|
||||
0x00, 0x00, response_size as u8, 0x00,
|
||||
];
|
||||
let mut response = vec![0u8; response_size as usize];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
if response.len() >= 4 {
|
||||
let got = &response[0..4];
|
||||
if got != self.profile.drive_signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.drive_signature,
|
||||
got: got.try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if response.len() >= 16 && &response[12..16] != b"MMkv" {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: format!(
|
||||
"mode not active: {:02x}{:02x}{:02x}{:02x}",
|
||||
response[12], response[13], response[14], response[15]
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// If signature + MMkv match, version is almost always OK.
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Sends a short READ_BUFFER probe, retries up to 5 times.
|
||||
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x04, 0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform for Mt1959 {
|
||||
/// Thin wrapper → do_unlock(). Returns Ok if mode active, Err if not.
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Primary read: 0x760 (1888) bytes of configuration data.
|
||||
/// Secondary read: 4-byte status appended to the result.
|
||||
fn read_config(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
// Primary config read: 0x760 = 1888 bytes
|
||||
let cdb = self.read_buffer_cdb(0, 0x760);
|
||||
let mut buf = vec![0u8; 0x760];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
/// Two variants with different upload sequences:
|
||||
///
|
||||
/// 1. WRITE_BUFFER mode=6 with ld_microcode
|
||||
/// 2. READ_BUFFER buf=0x45 verify (expect response == 2)
|
||||
/// 3. do_unlock() × 2
|
||||
///
|
||||
/// 1. WRITE_BUFFER with mode=2 buf=0x77 initial handshake (0x9C0 bytes)
|
||||
/// 2. Check response == 2
|
||||
/// 3. READ_BUFFER at offset 0x3000 (16 bytes, firmware metadata check)
|
||||
/// 4. WRITE_BUFFER mode=6 with ld_microcode (16 bytes from payload+16)
|
||||
/// 5. READ verify
|
||||
/// 6. do_unlock() × 5 retries
|
||||
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
if microcode.is_empty() {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: "no ld_microcode in profile".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Secondary: 4-byte status read
|
||||
let cdb2 = self.read_buffer_cdb(0, 4);
|
||||
let mut status = [0u8; 4];
|
||||
scsi.execute(&cdb2, DataDirection::FromDevice, &mut status, 5_000)?;
|
||||
if self.mode == 0x01 {
|
||||
// ── MT1959-A path ──────────────────────────────────────────
|
||||
self.load_firmware_a(scsi)?;
|
||||
} else {
|
||||
// ── MT1959-B path ──────────────────────────────────────────
|
||||
self.load_firmware_b(scsi)?;
|
||||
}
|
||||
|
||||
buf.extend_from_slice(&status);
|
||||
Ok(buf)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handlers 2-3: Read hardware register at the profile-specified offset.
|
||||
///
|
||||
/// Reads 36 bytes via READ BUFFER and extracts bytes [4:20] as the
|
||||
/// 16-byte register value.
|
||||
fn read_register(&mut self, scsi: &mut dyn ScsiTransport, index: u8) -> Result<[u8; 16]> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
/// do_unlock → validate × 5 → send hardware_register_a_cdb (10B) →
|
||||
/// check 36B response → return response[4:20] (16 bytes)
|
||||
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
let offset = *self.profile.register_offsets.get(index as usize)
|
||||
.ok_or_else(|| Error::ProfileNotFound {
|
||||
vendor_id: self.profile.vendor_id.clone(),
|
||||
product_revision: self.profile.product_revision.clone(),
|
||||
vendor_specific: format!("register index {} out of range", index),
|
||||
})?;
|
||||
|
||||
let cdb = scsi::build_read_buffer(self.mode, self.buffer_id, offset, 36);
|
||||
let cdb = &self.profile.hardware_register_a_cdb;
|
||||
if cdb.len() != 10 {
|
||||
return Err(Error::UnlockFailed { detail: "missing register_a_cdb".into() });
|
||||
}
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
let cdb = &self.profile.hardware_register_b_cdb;
|
||||
if cdb.len() != 10 {
|
||||
return Err(Error::UnlockFailed { detail: "missing register_b_cdb".into() });
|
||||
}
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
@@ -196,94 +225,157 @@ impl Platform for Mt1959 {
|
||||
}
|
||||
|
||||
///
|
||||
/// Scans disc surface addresses via READ BUFFER sub-command 0x14 to
|
||||
/// build a 64-entry speed lookup table. Issues SET CD SPEED(max) when done.
|
||||
/// Full calibration sequence:
|
||||
/// 1. do_unlock()
|
||||
/// 2. init_timing()
|
||||
/// 3. read_buffer_probe(0x12, init_addr, 4) — calibration init
|
||||
/// 4. validate_with_retry()
|
||||
/// 5. memset speed_table to 0
|
||||
/// 6. First probe: sub_cmd=0x14, addr=0 → initial speed
|
||||
/// 7. Scan loop: addresses 0x0000-0x5800, step 0x100, detect zone boundaries
|
||||
/// 8. Build loop: scan all zones up to 0x10000, store in speed_table[(speed>>1)-1]
|
||||
/// 9. Triple SET_CD_SPEED: max → drive_nominal_speed → max
|
||||
/// 10. Store 4 calibration config bytes
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
self.validate(scsi)?;
|
||||
// Step 1: ensure unlocked
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
|
||||
// Initial probe: READ BUFFER sub_cmd=0x12
|
||||
let cdb = self.read_buffer_sub(0x12, 0, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Clear speed table
|
||||
self.speed_table = [0u16; 64];
|
||||
|
||||
// Scan disc surface — probe addresses up to 0x10000, 256 at a time
|
||||
let mut table_idx = 0usize;
|
||||
let mut addr: u32 = 0;
|
||||
while addr < 0x10000 && table_idx < 64 {
|
||||
let cdb = self.read_buffer_sub(0x14, addr as u16, 4);
|
||||
let mut resp = [0u8; 4];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
Ok(r) if r.bytes_transferred == 4 => {
|
||||
let val = resp[0];
|
||||
if val > 0 {
|
||||
let speed_entry = ((resp[0] as u16) << 8) | (resp[1] as u16);
|
||||
if speed_entry > 0 {
|
||||
self.speed_table[table_idx] = speed_entry;
|
||||
table_idx += 1;
|
||||
}
|
||||
}
|
||||
addr += 256;
|
||||
}
|
||||
_ => {
|
||||
addr += 256;
|
||||
}
|
||||
}
|
||||
// Step 2: read disc capacity
|
||||
let cap_cdb = [0x25u8, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let mut cap_buf = [0u8; 8];
|
||||
if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
|
||||
self.disc_sectors = u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1;
|
||||
}
|
||||
|
||||
// Set max speed after calibration
|
||||
self.set_cd_speed(scsi, 0xFFFF)?;
|
||||
// Step 3: calibration init — sub_cmd 0x12
|
||||
let init_addr: u16 = 0x0100; // TODO: detect disc type
|
||||
let mut init_resp = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x12, init_addr, &mut init_resp, 4);
|
||||
|
||||
// Step 4: validate
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Step 5: clear speed table
|
||||
self.speed_table = [0u16; 64];
|
||||
|
||||
// Step 6: first probe
|
||||
let mut probe_buf = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x14, 0, &mut probe_buf, 4);
|
||||
let initial_speed = probe_buf[0];
|
||||
self.calibration_config[0] = probe_buf[0];
|
||||
self.calibration_config[1] = probe_buf[1];
|
||||
self.calibration_config[2] = probe_buf[2];
|
||||
|
||||
// Step 7: scan loop — find zone boundaries (0x0000-0x5800, step 0x100)
|
||||
let mut addr: u16 = 0;
|
||||
let mut prev_speed = initial_speed;
|
||||
while addr < 0x5800 {
|
||||
let mut resp = [0u8; 4];
|
||||
if self.read_buffer_probe(scsi, 0x14, addr, &mut resp, 4).is_err() {
|
||||
self.speed_table = [0u16; 64];
|
||||
self.calibration_config = [0u8; 4];
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
if resp[0] != prev_speed {
|
||||
prev_speed = resp[0];
|
||||
}
|
||||
addr = addr.wrapping_add(0x100);
|
||||
}
|
||||
|
||||
// Step 8: build speed table — probe all zones up to 0x10000
|
||||
let mut addr: u32 = 0;
|
||||
let mut prev_speed: u8 = 0;
|
||||
while addr < 0x10000 {
|
||||
let mut resp = [0u8; 4];
|
||||
if self.read_buffer_probe(scsi, 0x14, addr as u16, &mut resp, 4).is_err() {
|
||||
break;
|
||||
}
|
||||
let speed = resp[0];
|
||||
if speed > prev_speed && speed > 0 {
|
||||
let idx = ((speed as usize) >> 1).saturating_sub(1);
|
||||
if idx < 64 && self.speed_table[idx] == 0 {
|
||||
self.speed_table[idx] = addr as u16;
|
||||
}
|
||||
}
|
||||
prev_speed = speed;
|
||||
addr += 0x100;
|
||||
}
|
||||
self.calibration_config[3] = prev_speed;
|
||||
|
||||
// Step 9: triple SET_CD_SPEED — max → nominal → max
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
// Send drive_nominal_speed_cdb (the specific speed from the profile)
|
||||
if self.profile.drive_nominal_speed_cdb.len() >= 6 {
|
||||
let cdb = &self.profile.drive_nominal_speed_cdb;
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
self.calibrated = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// NOT a no-op. Sends 16 bytes from a data buffer to the host via host_write.
|
||||
/// In our context: the x86 reads 16 bytes back. We return the data.
|
||||
/// For now we just acknowledge — the x86 calls this to confirm VM is alive.
|
||||
fn keepalive(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// In driver context: no host_write mechanism. This is a VM-to-host
|
||||
// communication that doesn't translate to a SCSI command.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Sends READ BUFFER with sub-command 0x13, reads 36 bytes.
|
||||
/// Checks signature at [0:4], returns feature data from [4:20].
|
||||
/// Full status check:
|
||||
/// 1. Read 4 bytes from host (param input)
|
||||
/// 2. Validate param == 4
|
||||
/// 3. do_unlock()
|
||||
/// 4. validate_with_retry()
|
||||
/// 6. read_buffer_probe(0x13, addr, response, 36)
|
||||
/// 7. validate_with_retry() again
|
||||
/// 8. Check REV32(response[0:4]) == 0x00220054
|
||||
/// 9. If mismatch: call fallback() with speed_zone_table
|
||||
/// 10. If match: host_write(response[4:20]) — 16 bytes features
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus> {
|
||||
self.ensure_unlocked(scsi)?;
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
// READ BUFFER with sub_cmd=0x13, 36 bytes response
|
||||
let cdb = self.read_buffer_sub(0x13, 0, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// Verify response signature
|
||||
let got_sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
|
||||
let expected_sig = u32::from_le_bytes(self.profile.signature);
|
||||
self.validate(scsi)?;
|
||||
|
||||
let sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
|
||||
let expected = 0x00220054;
|
||||
|
||||
let mut features = [0u8; 16];
|
||||
features.copy_from_slice(&response[4..20]);
|
||||
|
||||
Ok(DriveStatus {
|
||||
unlocked: got_sig == expected_sig,
|
||||
unlocked: sig == expected,
|
||||
features,
|
||||
})
|
||||
}
|
||||
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
|
||||
///
|
||||
/// Three code paths based on param count:
|
||||
/// param=1 (5 bytes in): sub_cmd + nothing else → dynamic_read_buffer
|
||||
/// param=5 (5+4 bytes in): sub_cmd + address → dynamic_read_buffer with addr
|
||||
/// param=9 (9+ bytes in): builds full 12-byte CDB on stack:
|
||||
/// [0x3C, mode, buf_id, sub_cmd, addr[2], addr[1], addr[0], len[2], len[1], len[0]]
|
||||
/// then calls scsi_cmd_wrapper directly
|
||||
fn probe(
|
||||
&mut self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u32, length: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C,
|
||||
self.mode,
|
||||
self.buffer_id,
|
||||
sub_cmd,
|
||||
(address >> 16) as u8,
|
||||
(address >> 8) as u8,
|
||||
address as u8,
|
||||
(length >> 16) as u8,
|
||||
(length >> 8) as u8,
|
||||
length as u8,
|
||||
0x3C, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 16) as u8, (address >> 8) as u8, address as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
];
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
@@ -292,34 +384,130 @@ impl Platform for Mt1959 {
|
||||
}
|
||||
|
||||
///
|
||||
/// Looks up the LBA in the speed table, issues SET CD SPEED if calibrated,
|
||||
/// then performs READ(10) with the raw read flag (0x08).
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
) -> Result<usize> {
|
||||
if !self.unlocked {
|
||||
return Err(Error::NotUnlocked);
|
||||
/// Speed management for content reads. Called per zone change.
|
||||
///
|
||||
/// 1. Read 4-byte target LBA from host
|
||||
/// 2. REV(LBA) for big-endian comparison
|
||||
/// 3. Search speed_table[64] for closest entry to LBA
|
||||
/// - Each entry is u16 zone boundary address
|
||||
/// - Find entry with smallest |entry - LBA| distance
|
||||
/// 4. If no match found → call fallback() with speed_calc_table
|
||||
/// 5. If match:
|
||||
/// a. r6 = speed_table[best_idx] (the zone address = speed value)
|
||||
/// b. Position check: read_buffer_probe(0x14, 0x100 | rev16(r6), 4)
|
||||
/// c. SET_CD_SPEED max (BB 00 FF FF FF FF)
|
||||
/// d. Build custom SET_CD_SPEED: BB 00 [r6>>8] [r6&FF] FF FF 00...
|
||||
/// e. Send custom SET_CD_SPEED
|
||||
/// f. Return next speed_table entry to host
|
||||
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
if !self.calibrated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Speed optimization from calibration
|
||||
if self.calibrated {
|
||||
let speed = self.lookup_speed(lba);
|
||||
if speed > 0 {
|
||||
let _ = self.set_cd_speed(scsi, speed);
|
||||
// the byte-swapped LBA against table entries. Since entries are
|
||||
// zone boundary addresses (u16), we compare the low 16 bits.
|
||||
|
||||
// Step 2-3: search speed_table for closest entry
|
||||
let mut best_idx: usize = 0;
|
||||
let mut best_diff: u32 = 0x10000000;
|
||||
let mut found = false;
|
||||
|
||||
for i in 0..64 {
|
||||
let entry = self.speed_table[i] as u32;
|
||||
if entry == 0 { continue; }
|
||||
let diff = if lba > entry { lba - entry } else { entry - lba };
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best_idx = i;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
// READ(10) with raw flag 0x08
|
||||
let cdb = scsi::build_read10_raw(lba, count);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 30_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
if !found {
|
||||
// For now, skip speed adjustment when no table match
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 5a: get the matched speed value
|
||||
let speed_val = self.speed_table[best_idx];
|
||||
|
||||
// Step 5b: position check probe
|
||||
let probe_addr = 0x0100 | (speed_val.swap_bytes() as u16);
|
||||
let mut probe_resp = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x14, probe_addr, &mut probe_resp, 4);
|
||||
|
||||
// Step 5c: SET_CD_SPEED max
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
// Step 5d-e: custom SET_CD_SPEED with the matched speed value
|
||||
let _ = self.set_cd_speed(scsi, speed_val);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Reads 8 bytes from host, byte-swaps as 32-bit value, returns.
|
||||
/// Has a helper for unaligned 4-byte reads with manual byte copy.
|
||||
/// In driver context: no host communication, so this is a no-op.
|
||||
fn timing(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// VM-to-host timing measurement, not a SCSI command.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full init sequence — matches x86 dispatch exactly.
|
||||
///
|
||||
/// Phase 1: cmd 0 → [cmd 1 if fail] × 6 retries (unlock + fw upload)
|
||||
/// Phase 2: cmd 4 × 6 retries (calibrate)
|
||||
/// Phase 3: cmd 7 → [cmd 5 fallback] (drive info)
|
||||
/// Phase 4: cmd 2 + cmd 3 × 5 retries (registers)
|
||||
/// Phase 5: cmd 9 × 6 retries (status)
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// Phase 1: Unlock + firmware upload (6 retries)
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
match self.unlock(scsi) {
|
||||
Ok(_) => { unlocked = true; break; }
|
||||
Err(_) => {
|
||||
if self.load_firmware(scsi).is_ok() {
|
||||
unlocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: "failed after 6 attempts (unlock + load_firmware)".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Calibrate (6 retries)
|
||||
let mut calibrated = false;
|
||||
for _attempt in 0..6 {
|
||||
if self.calibrate(scsi).is_ok() {
|
||||
calibrated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !calibrated {
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
|
||||
// If fails: cmd 5 fallback (keepalive)
|
||||
// In our context: this fetches a display string, not critical for reads
|
||||
let _ = self.probe(scsi, 0x00, 0, 0x3FF);
|
||||
|
||||
for _attempt in 0..5 {
|
||||
let a_ok = self.read_register_a(scsi).is_ok();
|
||||
let b_ok = self.read_register_b(scsi).is_ok();
|
||||
if a_ok && b_ok { break; }
|
||||
}
|
||||
|
||||
// Phase 5: Status (6 retries)
|
||||
for _attempt in 0..6 {
|
||||
if self.status(scsi).is_ok() { break; }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -327,3 +515,89 @@ impl Platform for Mt1959 {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private firmware upload variants ───────────────────────────────────
|
||||
|
||||
impl Mt1959 {
|
||||
///
|
||||
/// Simple: WRITE all microcode → verify buf=0x45 → unlock × 2.
|
||||
fn load_firmware_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
let len = microcode.len();
|
||||
|
||||
// WRITE_BUFFER mode=6: send entire microcode payload
|
||||
let cdb = [
|
||||
0x3B, 0x06, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(len >> 16) as u8, (len >> 8) as u8, len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = microcode.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
let verify_cdb = [0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00];
|
||||
let mut verify_resp = [0u8; 4];
|
||||
let _ = scsi.execute(
|
||||
&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000,
|
||||
);
|
||||
|
||||
// Unlock × 2
|
||||
self.do_unlock(scsi)?;
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// 1. MODE SELECT (0x55) — send 2496 bytes from ld_microcode
|
||||
/// 2. Check result == 2 (firmware accepted)
|
||||
/// 3. READ_BUFFER mode=6 offset=0x3000 (16 bytes firmware metadata)
|
||||
/// 4. WRITE_BUFFER mode=6 (16 bytes from profile's fw_write_data)
|
||||
/// 5. Vendor verify command (from profile's verify_cdb)
|
||||
/// 6. do_unlock() × 5 retries + 1 final
|
||||
fn load_firmware_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
|
||||
// Step 1: MODE SELECT (0x55) with vendor mode page data
|
||||
// Transfer: 0x9C0 = 2496 bytes from the firmware payload
|
||||
let write_len = 0x9C0usize.min(microcode.len());
|
||||
let mode_select_cdb = [
|
||||
0x55, 0x10, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = microcode[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// The execute above will Err on SCSI failure. If we get here, command accepted.
|
||||
|
||||
// Step 3: READ_BUFFER mode=6, offset=0x3000, 16 bytes
|
||||
let read_meta_cdb = [0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
|
||||
|
||||
// Step 4: WRITE_BUFFER mode=6, 16 bytes
|
||||
// This data is stored in profile.fw_write_data (16 bytes)
|
||||
if self.profile.fw_write_data.len() >= 16 {
|
||||
let write2_cdb = [0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut data2 = self.profile.fw_write_data[..16].to_vec();
|
||||
let _ = scsi.execute(&write2_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
}
|
||||
|
||||
// Step 5: Vendor verify command
|
||||
if self.profile.verify_cdb.len() >= 10 {
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&self.profile.verify_cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
// Step 6: do_unlock() × 5 retries + 1 confirmation
|
||||
for _attempt in 0..5 {
|
||||
if self.do_unlock(scsi).is_ok() {
|
||||
let _ = self.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+94
-140
@@ -1,20 +1,21 @@
|
||||
//! Drive profile loading and matching.
|
||||
//!
|
||||
//! Each supported drive has a profile containing the SCSI command
|
||||
//! parameters needed to enable raw disc access mode. Profiles are
|
||||
//! loaded from JSON files so new drives can be added without rebuilding.
|
||||
//! The profile contains all per-drive data needed by the MT1959 platform handlers.
|
||||
//! Profiles are loaded from JSON so new drives can be added without rebuilding.
|
||||
|
||||
use serde::Deserialize;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Per-drive profile containing SCSI parameters for raw disc access.
|
||||
///
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
// ── Drive identity (from INQUIRY + GET_CONFIG) ─────────────────────
|
||||
|
||||
/// Drive vendor from INQUIRY[8:16] (e.g. "HL-DT-ST")
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
|
||||
/// Drive product (devtype) from INQUIRY product field (e.g. "BD-RE")
|
||||
/// Drive product from INQUIRY[16:32] (e.g. "BD-RE BU40N")
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
|
||||
@@ -30,42 +31,77 @@ pub struct DriveProfile {
|
||||
#[serde(default)]
|
||||
pub firmware_date: String,
|
||||
|
||||
/// Chipset manufacturer determining unlock/read command structure.
|
||||
// ── Platform variant ───────────────────────────────────────────────
|
||||
|
||||
/// Chipset family: "mediatek" or "renesas".
|
||||
#[serde(default)]
|
||||
pub chipset: Chipset,
|
||||
|
||||
/// READ BUFFER mode byte for unlock CDB (e.g. 0x01 for MT1959-A, 0x02 for MT1959-B).
|
||||
/// Program variant: "mt1959_a" or "mt1959_b".
|
||||
/// Determines unlock mode/buf_id and handler layout.
|
||||
#[serde(default)]
|
||||
pub program: String,
|
||||
|
||||
/// READ_BUFFER mode byte (0x01 for mt1959_a, 0x02 for mt1959_b).
|
||||
#[serde(default = "default_unlock_mode")]
|
||||
pub unlock_mode: u8,
|
||||
|
||||
/// READ BUFFER buffer ID for unlock CDB (e.g. 0x44 for MT1959-A, 0x77 for MT1959-B).
|
||||
/// READ_BUFFER buffer ID (0x44 for mt1959_a, 0x77 for mt1959_b).
|
||||
#[serde(default = "default_unlock_buf_id")]
|
||||
pub unlock_buf_id: u8,
|
||||
|
||||
|
||||
/// Drive identifier string from the profile database.
|
||||
#[serde(default)]
|
||||
pub drive_id: String,
|
||||
/// Used with unlock_response_size_minus_init to compute response size.
|
||||
#[serde(default = "default_init_value")]
|
||||
pub unlock_init_value: u8,
|
||||
|
||||
/// Profile version string.
|
||||
#[serde(default)]
|
||||
pub drive_version: String,
|
||||
/// Response size = unlock_init_value + this (e.g. 1 + 63 = 64).
|
||||
#[serde(default = "default_response_size_minus_init")]
|
||||
pub unlock_response_size_minus_init: u8,
|
||||
|
||||
/// Expected response signature bytes [0:4] from the enable command.
|
||||
/// Per-drive signature checked against unlock response[0:4].
|
||||
#[serde(default, deserialize_with = "deserialize_hex4")]
|
||||
pub signature: [u8; 4],
|
||||
pub drive_signature: [u8; 4],
|
||||
|
||||
/// Expected verification bytes [12:16] from the enable response.
|
||||
#[serde(skip, default = "default_verify")]
|
||||
pub verify: [u8; 4],
|
||||
|
||||
/// 10-byte READ BUFFER CDB used to enable raw disc access.
|
||||
/// Uploaded on cold boot when unlock fails. ~1888 bytes typically.
|
||||
/// Contains volatile RAM-only runtime code for the drive's MediaTek SOC.
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub ld_microcode: Vec<u8>,
|
||||
|
||||
// ── Handlers 2/3: register reads ──────────────────────────────────
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub unlock_cdb: Vec<u8>,
|
||||
pub hardware_register_a_cdb: Vec<u8>,
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub hardware_register_b_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// 16 bytes written via WRITE_BUFFER mode=6 during B firmware upload.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub fw_write_data: Vec<u8>,
|
||||
|
||||
/// Vendor-specific verify CDB after B firmware upload (10 bytes).
|
||||
/// B-only. Empty for A-variant drives.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub verify_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// Pre-built SET_CD_SPEED CDB with drive's nominal speed (12 bytes).
|
||||
/// Used in calibration "triple play": max → this → max.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub drive_nominal_speed_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// for per-zone speed decisions. Per-drive calibration constants.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub speed_zone_table: Vec<u8>,
|
||||
|
||||
/// raw sector reads for speed math.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub speed_calc_table: Vec<u8>,
|
||||
|
||||
/// Register read offsets (bytes 3-5 of READ BUFFER CDB).
|
||||
#[serde(default)]
|
||||
pub register_offsets: Vec<u32>,
|
||||
|
||||
/// Drive supports reading DVDs regardless of region code.
|
||||
#[serde(default)]
|
||||
@@ -82,41 +118,35 @@ pub struct DriveProfile {
|
||||
/// Drive supports unrestricted read speed.
|
||||
#[serde(default)]
|
||||
pub unrestricted_speed: bool,
|
||||
|
||||
// ── Metadata ──────────────────────────────────────────────────────
|
||||
|
||||
#[serde(default)]
|
||||
pub drive_id: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub drive_version: String,
|
||||
}
|
||||
|
||||
fn default_verify() -> [u8; 4] {
|
||||
*b"MMkv"
|
||||
}
|
||||
fn default_unlock_mode() -> u8 { 0x01 }
|
||||
fn default_unlock_buf_id() -> u8 { 0x44 }
|
||||
fn default_init_value() -> u8 { 1 }
|
||||
fn default_response_size_minus_init() -> u8 { 0x3F }
|
||||
|
||||
fn default_unlock_mode() -> u8 {
|
||||
0x01
|
||||
}
|
||||
|
||||
fn default_unlock_buf_id() -> u8 {
|
||||
0x44
|
||||
}
|
||||
|
||||
/// Drive chipset — determines CDB structure for unlock and raw read commands.
|
||||
/// Drive chipset family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
pub enum Chipset {
|
||||
/// MediaTek MT1959 — LG, ASUS, hp drives.
|
||||
/// CDB: READ_BUFFER with mode and buf_id from profile.
|
||||
#[serde(rename = "mediatek")]
|
||||
MediaTek,
|
||||
/// Renesas RS8xxx/RS9xxx — Pioneer, some HL-DT-ST drives.
|
||||
/// Not yet implemented.
|
||||
#[serde(rename = "renesas")]
|
||||
Renesas,
|
||||
}
|
||||
|
||||
impl Default for Chipset {
|
||||
fn default() -> Self {
|
||||
Chipset::MediaTek
|
||||
}
|
||||
fn default() -> Self { Chipset::MediaTek }
|
||||
}
|
||||
|
||||
impl Chipset {
|
||||
/// Human-readable name for this chipset.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Chipset::MediaTek => "MediaTek MT1959",
|
||||
@@ -125,7 +155,8 @@ impl Chipset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hex string like "999ec375" into [u8; 4].
|
||||
// ── Hex/base64 parsing ─────────────────────────────────────────────────
|
||||
|
||||
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
||||
if s.len() != 8 {
|
||||
return Err(Error::ProfileParse { detail: format!("expected 8 hex chars, got {}", s.len()) });
|
||||
@@ -138,7 +169,6 @@ fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse a hex string into a byte vector.
|
||||
fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(Error::ProfileParse { detail: "odd hex length".into() });
|
||||
@@ -151,94 +181,33 @@ fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for 4-byte hex signature strings.
|
||||
fn deserialize_hex4<'de, D>(deserializer: D) -> std::result::Result<[u8; 4], D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
where D: serde::Deserializer<'de> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok([0; 4]); }
|
||||
parse_hex4(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Custom serde deserializer for hex-encoded byte vectors.
|
||||
fn deserialize_hex_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
where D: serde::Deserializer<'de> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok(Vec::new()); }
|
||||
parse_hex(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Load a profile from a parsed JSON value.
|
||||
pub fn load_from_json(json: &serde_json::Value) -> Result<DriveProfile> {
|
||||
let vendor = json["vendor_id"].as_str().unwrap_or("").to_string();
|
||||
let product = json["product_id"].as_str().unwrap_or("").to_string();
|
||||
let revision = json["product_revision"].as_str().unwrap_or("").to_string();
|
||||
let firmware_type = json["vendor_specific"].as_str().unwrap_or("").to_string();
|
||||
let firmware_date = json["firmware_date"].as_str().unwrap_or("").to_string();
|
||||
let chipset_str = json["chipset"].as_str().unwrap_or("unknown");
|
||||
|
||||
let chipset = match chipset_str {
|
||||
"mediatek" => Chipset::MediaTek,
|
||||
"renesas" => Chipset::Renesas,
|
||||
_ => Chipset::MediaTek,
|
||||
};
|
||||
|
||||
let unlock_mode = json["unlock_mode"].as_u64().map(|v| v as u8).unwrap_or(0x01);
|
||||
let unlock_buf_id = json["unlock_buf_id"].as_u64().map(|v| v as u8).unwrap_or(0x44);
|
||||
|
||||
let sig_str = json["signature"].as_str().unwrap_or("");
|
||||
|
||||
let signature = if sig_str.len() == 8 {
|
||||
parse_hex4(sig_str)?
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
|
||||
let unlock_cdb = json["unlock_cdb"].as_str()
|
||||
.map(|s| parse_hex(s))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let register_offsets = json["register_cdbs"].as_array()
|
||||
.map(|arr| {
|
||||
arr.iter().filter_map(|v| {
|
||||
let s = v.as_str()?;
|
||||
// CDB format: 3c 01 44 XX XX XX 00 00 24 00
|
||||
// Register offset is bytes 3-5 (chars 6-12 in hex)
|
||||
if s.len() >= 12 {
|
||||
u32::from_str_radix(&s[6..12], 16).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DriveProfile {
|
||||
vendor_id: vendor,
|
||||
product_id: product,
|
||||
product_revision: revision,
|
||||
vendor_specific: firmware_type,
|
||||
firmware_date,
|
||||
chipset,
|
||||
unlock_mode,
|
||||
unlock_buf_id,
|
||||
drive_id: json["drive_id"].as_str().unwrap_or("").to_string(),
|
||||
drive_version: json["drive_version"].as_str().unwrap_or("").to_string(),
|
||||
signature,
|
||||
verify: *b"MMkv",
|
||||
unlock_cdb,
|
||||
register_offsets,
|
||||
dvd_all_regions: json["capabilities"]["dvd_all_regions"].as_bool().unwrap_or(false),
|
||||
bd_raw_read: json["capabilities"]["bd_raw_read"].as_bool().unwrap_or(false),
|
||||
bd_raw_metadata: json["capabilities"]["bd_raw_metadata"].as_bool().unwrap_or(false),
|
||||
unrestricted_speed: json["capabilities"]["unrestricted_speed"].as_bool().unwrap_or(false),
|
||||
})
|
||||
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
use base64::Engine;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok(Vec::new()); }
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&s)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bundled profiles — compiled into the binary.
|
||||
/// Override with load_all() to load from a file instead.
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
|
||||
/// Load profiles from the bundled database.
|
||||
@@ -252,28 +221,13 @@ pub fn load_all(path: &std::path::Path) -> Result<Vec<DriveProfile>> {
|
||||
load_from_str(&data)
|
||||
}
|
||||
|
||||
/// Parse profiles from a JSON string.
|
||||
fn load_from_str(data: &str) -> Result<Vec<DriveProfile>> {
|
||||
let json: serde_json::Value = serde_json::from_str(data)
|
||||
let arr: Vec<DriveProfile> = serde_json::from_str(data)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("JSON: {e}") })?;
|
||||
|
||||
let arr = json.as_array()
|
||||
.ok_or_else(|| Error::ProfileParse { detail: "expected array".into() })?;
|
||||
|
||||
let mut profiles = Vec::with_capacity(arr.len());
|
||||
for entry in arr {
|
||||
match load_from_json(entry) {
|
||||
Ok(p) => profiles.push(p),
|
||||
Err(_) => continue, // skip malformed entries
|
||||
}
|
||||
}
|
||||
Ok(profiles)
|
||||
Ok(arr)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// Matches by vendor + product + revision + vendor_specific (firmware type).
|
||||
/// All fields trimmed before comparison.
|
||||
pub fn find_by_drive_id<'a>(
|
||||
profiles: &'a [DriveProfile],
|
||||
drive_id: &crate::identity::DriveId,
|
||||
@@ -282,14 +236,14 @@ pub fn find_by_drive_id<'a>(
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
|
||||
// Match all four INQUIRY fields for precise identification
|
||||
// Match all four INQUIRY fields
|
||||
profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
&& p.firmware_date.trim() == drive_id.firmware_date.trim()
|
||||
})
|
||||
// Fallback: match without date (for drives where 010C isn't available)
|
||||
// Fallback: match without date
|
||||
.or_else(|| profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
|
||||
@@ -116,4 +116,5 @@ impl ScsiTransport for SgIoTransport {
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -368,6 +368,7 @@ impl ScsiTransport for MacScsiTransport {
|
||||
sense,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
|
||||
@@ -56,6 +56,7 @@ pub trait ScsiTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
}
|
||||
|
||||
// ── Platform-agnostic open ──────────────────────────────────────────────────
|
||||
|
||||
+209
-18
@@ -26,11 +26,15 @@ use crate::drive::DriveSession;
|
||||
pub struct UdfFs {
|
||||
/// Root directory with full tree
|
||||
pub root: DirEntry,
|
||||
/// UDF Volume Identifier from Primary Volume Descriptor
|
||||
pub volume_id: String,
|
||||
/// Physical partition start (absolute sector)
|
||||
partition_start: u32,
|
||||
/// Metadata partition start (absolute sector)
|
||||
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
|
||||
metadata_start: u32,
|
||||
/// Metadata partition size in sectors
|
||||
metadata_sectors: u32,
|
||||
}
|
||||
|
||||
/// A directory or file entry.
|
||||
@@ -47,6 +51,12 @@ pub struct DirEntry {
|
||||
}
|
||||
|
||||
impl UdfFs {
|
||||
/// Physical partition start sector.
|
||||
pub fn partition_start(&self) -> u32 { self.partition_start }
|
||||
|
||||
/// Metadata partition start sector.
|
||||
pub fn metadata_start(&self) -> u32 { self.metadata_start }
|
||||
|
||||
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
|
||||
/// Path matching is case-insensitive.
|
||||
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
|
||||
@@ -62,6 +72,28 @@ impl UdfFs {
|
||||
|
||||
/// Read a file by path, returning its raw bytes.
|
||||
/// Reads sector by sector from disc — no buffering.
|
||||
/// Get the absolute starting LBA of a file on disc.
|
||||
/// Used by the rip pipeline to locate m2ts content sectors.
|
||||
pub fn file_start_lba(&self, session: &mut DriveSession, path: &str) -> Result<u32> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("directory not found: {}", part),
|
||||
})?;
|
||||
}
|
||||
let filename = parts.last().unwrap();
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("file not found: {}", path),
|
||||
})?;
|
||||
let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
|
||||
pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result<Vec<u8>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
@@ -102,6 +134,59 @@ impl UdfFs {
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Collect all sector ranges needed for disc-info and AACS.
|
||||
///
|
||||
/// Returns a list of (start_lba, sector_count) ranges covering:
|
||||
/// - UDF structure (AVDP, VDS, metadata partition, directories)
|
||||
/// - BDMV/PLAYLIST/*.mpls, CLIPINF/*.clpi, JAR/*, META/*, *.bdmv
|
||||
/// - AACS/* (Content*.cer, Unit_Key_RO.inf, CPSUnit*.cci)
|
||||
///
|
||||
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
|
||||
/// MKB_RO.inf, ContentHash*, ContentRevocation*
|
||||
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
|
||||
let mut ranges = Vec::new();
|
||||
|
||||
// UDF structure: sector 0 through end of metadata partition
|
||||
// Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
|
||||
let meta_end = self.metadata_start + self.metadata_sectors;
|
||||
ranges.push((0, meta_end));
|
||||
|
||||
// Walk tree, collect ranges for each metadata file
|
||||
self.collect_file_ranges(session, &self.root, &mut ranges)?;
|
||||
|
||||
// Merge overlapping/adjacent ranges and sort
|
||||
ranges.sort_by_key(|r| r.0);
|
||||
let merged = merge_ranges(&ranges);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
|
||||
for child in &entry.entries {
|
||||
if child.is_dir {
|
||||
// Only skip STREAM — those are the multi-GB video files
|
||||
if child.name.eq_ignore_ascii_case("STREAM") {
|
||||
continue;
|
||||
}
|
||||
self.collect_file_ranges(session, child, ranges)?;
|
||||
} else {
|
||||
// Include the ICB sector itself (in metadata partition)
|
||||
ranges.push((self.meta_to_abs(child.meta_lba), 1));
|
||||
|
||||
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
|
||||
if child.size > 50_000_000 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
|
||||
let abs_start = self.partition_start + data_lba;
|
||||
let sector_count = (data_len + 2047) / 2048;
|
||||
ranges.push((abs_start, sector_count));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a metadata-partition-relative LBA to an absolute sector number.
|
||||
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
|
||||
self.metadata_start + meta_lba
|
||||
@@ -109,45 +194,94 @@ impl UdfFs {
|
||||
|
||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||
/// and return its first allocation extent: (data_lba, data_length).
|
||||
/// The data_lba is metadata-relative.
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(session, meta_lba)?;
|
||||
extents.first().copied().ok_or_else(|| Error::DiscError {
|
||||
detail: "no allocation descriptors in ICB".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
|
||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents).
|
||||
fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result<Vec<(u32, u32)>> {
|
||||
let mut icb = [0u8; 2048];
|
||||
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
|
||||
|
||||
let tag = u16::from_le_bytes([icb[0], icb[1]]);
|
||||
|
||||
// Get allocation descriptor offset based on ICB type
|
||||
let ad_offset = match tag {
|
||||
// Get allocation descriptor offset and total length based on ICB type
|
||||
let (ad_offset, l_ad) = match tag {
|
||||
// Extended File Entry (UDF 2.50, used by BD-ROM)
|
||||
// Layout: ... L_EA at [208:212], L_AD at [212:216], alloc descs at 216 + L_EA
|
||||
266 => {
|
||||
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
|
||||
216 + l_ea
|
||||
let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize;
|
||||
(216 + l_ea, l_ad)
|
||||
}
|
||||
// Standard File Entry
|
||||
// Layout: ... L_EA at [168:172], L_AD at [172:176], alloc descs at 176 + L_EA
|
||||
261 => {
|
||||
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
|
||||
176 + l_ea
|
||||
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
|
||||
(176 + l_ea, l_ad)
|
||||
}
|
||||
_ => return Err(Error::DiscError {
|
||||
detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba),
|
||||
}),
|
||||
};
|
||||
|
||||
if ad_offset + 8 > 2048 {
|
||||
return Err(Error::DiscError { detail: "ICB alloc desc out of range".into() });
|
||||
let mut extents = Vec::new();
|
||||
let num_descriptors = l_ad / 8; // Short Allocation Descriptor = 8 bytes
|
||||
|
||||
for i in 0..num_descriptors {
|
||||
let off = ad_offset + i * 8;
|
||||
if off + 8 > 2048 {
|
||||
break; // TODO: follow Allocation Extent Descriptors (tag 258) for overflow
|
||||
}
|
||||
|
||||
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
|
||||
let extent_type = raw_len >> 30;
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_lba = u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]);
|
||||
|
||||
match extent_type {
|
||||
0 => extents.push((data_lba, data_len)), // recorded and allocated
|
||||
1 => {} // allocated but not recorded (sparse) — skip
|
||||
3 => break, // next extent of allocation descriptors — TODO
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Short Allocation Descriptor: extent_length(4) + extent_position(4)
|
||||
// extent_length upper 2 bits = type (0=recorded, 1=allocated not recorded, 3=next extent)
|
||||
let raw_len = u32::from_le_bytes([icb[ad_offset], icb[ad_offset + 1],
|
||||
icb[ad_offset + 2], icb[ad_offset + 3]]);
|
||||
let data_len = raw_len & 0x3FFFFFFF;
|
||||
let data_lba = u32::from_le_bytes([icb[ad_offset + 4], icb[ad_offset + 5],
|
||||
icb[ad_offset + 6], icb[ad_offset + 7]]);
|
||||
Ok(extents)
|
||||
}
|
||||
|
||||
Ok((data_lba, data_len))
|
||||
/// Get all absolute disc sector extents for a file.
|
||||
/// Returns Vec of (absolute_lba, sector_count) covering the entire file.
|
||||
pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result<Vec<(u32, u32)>> {
|
||||
let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
|
||||
let mut current = &self.root;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
current = current.entries.iter().find(|e| {
|
||||
e.is_dir && e.name.eq_ignore_ascii_case(part)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("directory not found: {}", part),
|
||||
})?;
|
||||
}
|
||||
let filename = parts.last().unwrap();
|
||||
let entry = current.entries.iter().find(|e| {
|
||||
!e.is_dir && e.name.eq_ignore_ascii_case(filename)
|
||||
}).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("file not found: {}", path),
|
||||
})?;
|
||||
|
||||
let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?;
|
||||
let mut disc_extents = Vec::new();
|
||||
for (lba, byte_len) in alloc_extents {
|
||||
let abs_lba = self.partition_start + lba;
|
||||
let sectors = ((byte_len as u64 + 2047) / 2048) as u32;
|
||||
disc_extents.push((abs_lba, sectors));
|
||||
}
|
||||
Ok(disc_extents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +315,8 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let mut partition_start: u32 = 0;
|
||||
let mut num_partition_maps: u32 = 0;
|
||||
let mut lvd_sector: Option<u32> = None;
|
||||
let mut volume_id = String::new();
|
||||
let mut metadata_size_bytes: u32 = 0;
|
||||
|
||||
for i in 32..64 {
|
||||
let mut desc = [0u8; 2048];
|
||||
@@ -188,6 +324,10 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
|
||||
match desc_tag {
|
||||
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
|
||||
1 => {
|
||||
volume_id = parse_dstring(&desc[24..56]);
|
||||
}
|
||||
// Partition Descriptor — tells us where the physical partition starts
|
||||
5 => {
|
||||
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
|
||||
@@ -241,8 +381,9 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
|
||||
meta_icb[210], meta_icb[211]]) as usize;
|
||||
let ad_off = 216 + l_ea;
|
||||
let _ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
|
||||
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
|
||||
metadata_size_bytes = ad_len;
|
||||
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
|
||||
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
|
||||
// Metadata content starts at partition_start + ad_pos
|
||||
@@ -281,10 +422,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
// Step 5: Read root directory and build file tree
|
||||
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
|
||||
|
||||
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
|
||||
|
||||
Ok(UdfFs {
|
||||
root,
|
||||
volume_id,
|
||||
partition_start,
|
||||
metadata_start,
|
||||
metadata_sectors,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -456,6 +601,52 @@ fn parse_udf_name(data: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge overlapping or adjacent (start, count) ranges.
|
||||
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
|
||||
if ranges.is_empty() { return Vec::new(); }
|
||||
let mut result = vec![ranges[0]];
|
||||
for &(start, count) in &ranges[1..] {
|
||||
let last = result.last_mut().unwrap();
|
||||
let last_end = last.0 + last.1;
|
||||
if start <= last_end + 1 {
|
||||
// Overlapping or adjacent — extend
|
||||
let new_end = (start + count).max(last_end);
|
||||
last.1 = new_end - last.0;
|
||||
} else {
|
||||
result.push((start, count));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse a UDF d-string (fixed-length field with length byte at the end).
|
||||
/// Used for Volume Identifier and other UDF descriptor strings.
|
||||
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
|
||||
fn parse_dstring(data: &[u8]) -> String {
|
||||
if data.is_empty() { return String::new(); }
|
||||
let len = *data.last().unwrap() as usize;
|
||||
if len == 0 || len > data.len() { return String::new(); }
|
||||
let content = &data[..len];
|
||||
if content.is_empty() { return String::new(); }
|
||||
match content[0] {
|
||||
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
16 => {
|
||||
let mut s = String::new();
|
||||
let chars = &content[1..];
|
||||
for i in (0..chars.len()).step_by(2) {
|
||||
if i + 1 < chars.len() {
|
||||
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
|
||||
if c != 0 {
|
||||
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
|
||||
}
|
||||
}
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single 2048-byte sector from the drive.
|
||||
/// Uses standard READ(10) — no unlock required.
|
||||
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user