100% codec coverage + disc/ and aacs/ module refactors
Codec coverage (DVD + BD + UHD): - E-AC-3 (Dolby Digital Plus): bsid detection, frame size calc — 8 tests - DTS-HD MA/HR: extension substream (0x64582025) detection — 8 tests - LPCM: BD header skip, raw PCM extraction — 6 tests - DVD VobSub subtitles: passthrough parser — 5 tests - Dolby Vision: verified RPU NAL type 62 preserved in HEVC — 1 test Module refactors: - disc.rs → disc/mod.rs + bluray.rs + dvd.rs + encrypt.rs - aacs/mod.rs (1661 lines) → mod.rs (21) + keydb.rs + keys.rs + decrypt.rs - All public APIs preserved, all tests pass 270 tests total, 0 failures.
This commit is contained in:
@@ -0,0 +1,299 @@
|
|||||||
|
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
||||||
|
|
||||||
|
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||||
|
use aes::Aes128;
|
||||||
|
|
||||||
|
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fixed IV used by AACS for all AES-CBC operations.
|
||||||
|
pub(crate) const AACS_IV: [u8; 16] = [
|
||||||
|
0x0B, 0xA0, 0xF8, 0xDD, 0xFE, 0xA6, 0x1F, 0xB3, 0xD8, 0xDF, 0x9F, 0x56, 0x6A, 0x05, 0x0F, 0x78,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Size of an AACS aligned unit (3 × 2048-byte sectors).
|
||||||
|
pub const ALIGNED_UNIT_LEN: usize = 6144;
|
||||||
|
|
||||||
|
/// Size of one sector.
|
||||||
|
const SECTOR_LEN: usize = 2048;
|
||||||
|
|
||||||
|
/// Transport stream packet spacing in Blu-ray m2ts (192 bytes = 4 TP_extra + 188 TS).
|
||||||
|
const TS_PACKET_LEN: usize = 192;
|
||||||
|
|
||||||
|
/// TS sync byte.
|
||||||
|
const TS_SYNC: u8 = 0x47;
|
||||||
|
|
||||||
|
// ── AES primitives ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// AES-128-ECB encrypt a single 16-byte block.
|
||||||
|
pub(crate) fn aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let mut block = GenericArray::clone_from_slice(data);
|
||||||
|
cipher.encrypt_block(&mut block);
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&block);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-128-ECB decrypt a single 16-byte block.
|
||||||
|
pub fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let mut block = GenericArray::clone_from_slice(data);
|
||||||
|
cipher.decrypt_block(&mut block);
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&block);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
||||||
|
/// AES-128-CBC decrypt in-place with the fixed AACS IV.
|
||||||
|
pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let num_blocks = data.len() / 16;
|
||||||
|
// Process blocks in reverse to avoid clobbering ciphertext needed for XOR
|
||||||
|
for i in (0..num_blocks).rev() {
|
||||||
|
let offset = i * 16;
|
||||||
|
let prev = if i == 0 {
|
||||||
|
AACS_IV
|
||||||
|
} else {
|
||||||
|
let mut p = [0u8; 16];
|
||||||
|
p.copy_from_slice(&data[(i - 1) * 16..i * 16]);
|
||||||
|
p
|
||||||
|
};
|
||||||
|
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||||
|
cipher.decrypt_block(&mut block);
|
||||||
|
for j in 0..16 {
|
||||||
|
data[offset + j] = block[j] ^ prev[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content decryption ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
|
||||||
|
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||||
|
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
|
||||||
|
fn verify_ts(unit: &[u8]) -> bool {
|
||||||
|
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
|
||||||
|
// then 188-byte TS packet, repeating every 192 bytes.
|
||||||
|
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
|
||||||
|
let mut count = 0;
|
||||||
|
let mut offset = 4;
|
||||||
|
while offset < unit.len() {
|
||||||
|
if unit[offset] == TS_SYNC {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
offset += TS_PACKET_LEN;
|
||||||
|
}
|
||||||
|
// Expect at least most packets to have sync bytes
|
||||||
|
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
|
||||||
|
count > total / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||||
|
/// Returns true if decryption succeeded (verified by TS sync bytes).
|
||||||
|
///
|
||||||
|
/// Algorithm:
|
||||||
|
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
||||||
|
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
||||||
|
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
||||||
|
/// 4. Clear encryption flag bits
|
||||||
|
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||||
|
if unit.len() < ALIGNED_UNIT_LEN {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if !is_unit_encrypted(unit) {
|
||||||
|
return true; // not encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save original first 16 bytes (they're plaintext TP_extra_header)
|
||||||
|
let mut header = [0u8; 16];
|
||||||
|
header.copy_from_slice(&unit[..16]);
|
||||||
|
|
||||||
|
// Step 1: Encrypt header with unit key to derive per-unit key
|
||||||
|
let derived = aes_ecb_encrypt(unit_key, &header);
|
||||||
|
|
||||||
|
// Step 2: XOR to get the actual decryption key
|
||||||
|
let mut decrypt_key = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
decrypt_key[i] = derived[i] ^ header[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||||
|
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||||
|
|
||||||
|
// Step 4: Clear encryption flag
|
||||||
|
unit[0] &= !0xC0;
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
verify_ts(unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||||
|
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||||
|
if !is_unit_encrypted(unit) {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save original for retry
|
||||||
|
let original = unit[..ALIGNED_UNIT_LEN].to_vec();
|
||||||
|
|
||||||
|
for (i, key) in unit_keys.iter().enumerate() {
|
||||||
|
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
|
||||||
|
if decrypt_unit(unit, key) {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original on failure
|
||||||
|
unit[..ALIGNED_UNIT_LEN].copy_from_slice(&original);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
|
||||||
|
/// Bus encryption uses read_data_key, decrypting bytes 16..2047 of each 2048-byte sector.
|
||||||
|
pub fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
|
||||||
|
for sector_start in (0..ALIGNED_UNIT_LEN).step_by(SECTOR_LEN) {
|
||||||
|
if sector_start + SECTOR_LEN > unit.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// First 16 bytes of each sector are plaintext
|
||||||
|
aes_cbc_decrypt(
|
||||||
|
read_data_key,
|
||||||
|
&mut unit[sector_start + 16..sector_start + SECTOR_LEN],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full decrypt of an aligned unit: bus decrypt (if needed) then AACS decrypt.
|
||||||
|
pub fn decrypt_unit_full(
|
||||||
|
unit: &mut [u8],
|
||||||
|
unit_key: &[u8; 16],
|
||||||
|
read_data_key: Option<&[u8; 16]>,
|
||||||
|
) -> bool {
|
||||||
|
if !is_unit_encrypted(unit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(rdk) = read_data_key {
|
||||||
|
decrypt_bus(unit, rdk);
|
||||||
|
}
|
||||||
|
decrypt_unit(unit, unit_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aes_ecb_roundtrip() {
|
||||||
|
let key = [
|
||||||
|
0x15u8, 0x66, 0x5F, 0x98, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
|
||||||
|
0x0B, 0x0C,
|
||||||
|
];
|
||||||
|
let plain = [0x41u8; 16];
|
||||||
|
let enc = aes_ecb_encrypt(&key, &plain);
|
||||||
|
let dec = aes_ecb_decrypt(&key, &enc);
|
||||||
|
assert_eq!(dec, plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_unit_unencrypted() {
|
||||||
|
// Unit with 0xC0 bits clear should pass through unchanged
|
||||||
|
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
unit[0] = 0x00; // not encrypted
|
||||||
|
let key = [0u8; 16];
|
||||||
|
assert!(decrypt_unit(&mut unit, &key));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aes_cbc_roundtrip() {
|
||||||
|
let key = [
|
||||||
|
0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||||
|
0xFF, 0x00,
|
||||||
|
];
|
||||||
|
let original = vec![0x42u8; 128]; // 8 blocks
|
||||||
|
let mut data = original.clone();
|
||||||
|
|
||||||
|
// Encrypt with CBC manually (forward direction)
|
||||||
|
fn aes_cbc_encrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
let mut prev = super::AACS_IV;
|
||||||
|
let num_blocks = data.len() / 16;
|
||||||
|
for i in 0..num_blocks {
|
||||||
|
let offset = i * 16;
|
||||||
|
for j in 0..16 {
|
||||||
|
data[offset + j] ^= prev[j];
|
||||||
|
}
|
||||||
|
let mut block = GenericArray::clone_from_slice(&data[offset..offset + 16]);
|
||||||
|
cipher.encrypt_block(&mut block);
|
||||||
|
data[offset..offset + 16].copy_from_slice(&block);
|
||||||
|
prev.copy_from_slice(&data[offset..offset + 16]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aes_cbc_encrypt(&key, &mut data);
|
||||||
|
assert_ne!(data, original); // should be different after encrypt
|
||||||
|
|
||||||
|
super::aes_cbc_decrypt(&key, &mut data);
|
||||||
|
assert_eq!(data, original); // should match after roundtrip
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_unit_synthetic() {
|
||||||
|
// Build a fake 6144-byte aligned unit with known TS sync pattern,
|
||||||
|
// encrypt it with the AACS algorithm, then decrypt and verify.
|
||||||
|
let unit_key = [0xAAu8; 16];
|
||||||
|
|
||||||
|
// Build plaintext unit with TS sync bytes every 192 bytes starting at offset 4
|
||||||
|
let mut plain = vec![0u8; ALIGNED_UNIT_LEN];
|
||||||
|
let mut offset = 4;
|
||||||
|
while offset < ALIGNED_UNIT_LEN {
|
||||||
|
plain[offset] = TS_SYNC;
|
||||||
|
offset += TS_PACKET_LEN;
|
||||||
|
}
|
||||||
|
// Set encryption flag
|
||||||
|
plain[0] |= 0xC0;
|
||||||
|
|
||||||
|
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||||
|
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||||
|
let derived = aes_ecb_encrypt(&unit_key, &header);
|
||||||
|
let mut encrypt_key = [0u8; 16];
|
||||||
|
for i in 0..16 {
|
||||||
|
encrypt_key[i] = derived[i] ^ header[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CBC encrypt bytes 16..6143
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(&encrypt_key));
|
||||||
|
let mut prev = AACS_IV;
|
||||||
|
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
|
||||||
|
for i in 0..num_blocks {
|
||||||
|
let off = 16 + i * 16;
|
||||||
|
for j in 0..16 {
|
||||||
|
plain[off + j] ^= prev[j];
|
||||||
|
}
|
||||||
|
let mut block = GenericArray::clone_from_slice(&plain[off..off + 16]);
|
||||||
|
cipher.encrypt_block(&mut block);
|
||||||
|
plain[off..off + 16].copy_from_slice(&block);
|
||||||
|
prev.copy_from_slice(&plain[off..off + 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now plain contains encrypted data. Decrypt it.
|
||||||
|
let mut unit = plain;
|
||||||
|
assert!(is_unit_encrypted(&unit));
|
||||||
|
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||||
|
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
|
||||||
|
|
||||||
|
// Verify TS sync bytes
|
||||||
|
let mut count = 0;
|
||||||
|
let mut off = 4;
|
||||||
|
while off < ALIGNED_UNIT_LEN {
|
||||||
|
if unit[off] == TS_SYNC {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
off += TS_PACKET_LEN;
|
||||||
|
}
|
||||||
|
assert_eq!(count, (ALIGNED_UNIT_LEN - 4) / TS_PACKET_LEN + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
//! AACS Key Database parsing — KEYDB.cfg format.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Parsed AACS key database.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KeyDb {
|
||||||
|
/// Device keys for MKB processing
|
||||||
|
pub device_keys: Vec<DeviceKey>,
|
||||||
|
/// Processing keys (pre-computed media keys for specific MKB versions)
|
||||||
|
pub processing_keys: Vec<[u8; 16]>,
|
||||||
|
/// Host certificate + private key for SCSI authentication
|
||||||
|
pub host_certs: Vec<HostCert>,
|
||||||
|
/// Per-disc VUK entries indexed by disc hash (hex lowercase)
|
||||||
|
pub disc_entries: HashMap<String, DiscEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device key for MKB subset-difference tree processing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeviceKey {
|
||||||
|
pub key: [u8; 16],
|
||||||
|
pub node: u16,
|
||||||
|
pub uv: u32,
|
||||||
|
pub u_mask_shift: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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],
|
||||||
|
/// 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.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiscEntry {
|
||||||
|
/// Disc hash (20 bytes, hex)
|
||||||
|
pub disc_hash: String,
|
||||||
|
/// Disc title
|
||||||
|
pub title: String,
|
||||||
|
/// Media Key (16 bytes) — from MKB processing
|
||||||
|
pub media_key: Option<[u8; 16]>,
|
||||||
|
/// Disc ID (16 bytes)
|
||||||
|
pub disc_id: Option<[u8; 16]>,
|
||||||
|
/// Volume Unique Key (16 bytes) — decrypts title keys
|
||||||
|
pub vuk: Option<[u8; 16]>,
|
||||||
|
/// Unit keys (title keys) indexed by CPS unit number
|
||||||
|
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a hex string like "0xABCD..." into bytes.
|
||||||
|
pub(crate) fn parse_hex(s: &str) -> Option<Vec<u8>> {
|
||||||
|
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||||
|
if !s.len().is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(s.len() / 2);
|
||||||
|
for i in (0..s.len()).step_by(2) {
|
||||||
|
out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse hex into a fixed-size array.
|
||||||
|
pub(crate) fn parse_hex16(s: &str) -> Option<[u8; 16]> {
|
||||||
|
let v = parse_hex(s)?;
|
||||||
|
if v.len() != 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 16];
|
||||||
|
out.copy_from_slice(&v);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_hex20(s: &str) -> Option<[u8; 20]> {
|
||||||
|
let v = parse_hex(s)?;
|
||||||
|
if v.len() != 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = [0u8; 20];
|
||||||
|
out.copy_from_slice(&v);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyDb {
|
||||||
|
/// Parse a KEYDB.cfg file from a string.
|
||||||
|
pub fn parse(data: &str) -> Self {
|
||||||
|
let mut db = KeyDb {
|
||||||
|
device_keys: Vec::new(),
|
||||||
|
processing_keys: Vec::new(),
|
||||||
|
host_certs: Vec::new(),
|
||||||
|
disc_entries: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in data.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device Key
|
||||||
|
if line.starts_with("| DK") {
|
||||||
|
if let Some(dk) = Self::parse_device_key(line) {
|
||||||
|
db.device_keys.push(dk);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing Key
|
||||||
|
if line.starts_with("| PK") {
|
||||||
|
if let Some(pk) = Self::parse_processing_key(line) {
|
||||||
|
db.processing_keys.push(pk);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host Certificate (AACS 2.0)
|
||||||
|
if line.starts_with("| HC2") {
|
||||||
|
if let Some(hc) = db.host_certs.last_mut() {
|
||||||
|
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") {
|
||||||
|
if let Some(hc) = Self::parse_host_cert(line) {
|
||||||
|
db.host_certs.push(hc);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disc entry: starts with 0x
|
||||||
|
if line.starts_with("0x") && line.contains(" = ") {
|
||||||
|
if let Some(entry) = Self::parse_disc_entry(line) {
|
||||||
|
db.disc_entries.insert(entry.disc_hash.clone(), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load KEYDB.cfg from a file path.
|
||||||
|
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||||
|
let data = std::fs::read_to_string(path)?;
|
||||||
|
Ok(Self::parse(&data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a disc by its hash. Returns the VUK if found.
|
||||||
|
pub fn find_vuk(&self, disc_hash: &str) -> Option<[u8; 16]> {
|
||||||
|
let hash = disc_hash
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.trim_start_matches("0x")
|
||||||
|
.to_string();
|
||||||
|
// Try with 0x prefix and without
|
||||||
|
self.disc_entries
|
||||||
|
.get(&format!("0x{}", hash))
|
||||||
|
.or_else(|| self.disc_entries.get(&hash))
|
||||||
|
.and_then(|e| e.vuk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a disc by its hash. Returns the full entry.
|
||||||
|
pub fn find_disc(&self, disc_hash: &str) -> Option<&DiscEntry> {
|
||||||
|
let hash = disc_hash
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.trim_start_matches("0x")
|
||||||
|
.to_string();
|
||||||
|
self.disc_entries
|
||||||
|
.get(&format!("0x{}", hash))
|
||||||
|
.or_else(|| self.disc_entries.get(&hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn parse_device_key(line: &str) -> Option<DeviceKey> {
|
||||||
|
// | DK | DEVICE_KEY 0x... | DEVICE_NODE 0x... | KEY_UV 0x... | KEY_U_MASK_SHIFT 0x...
|
||||||
|
let key_str = line.split("DEVICE_KEY").nth(1)?.split('|').next()?.trim();
|
||||||
|
let node_str = line.split("DEVICE_NODE").nth(1)?.split('|').next()?.trim();
|
||||||
|
let uv_str = line.split("KEY_UV").nth(1)?.split('|').next()?.trim();
|
||||||
|
let shift_str = line
|
||||||
|
.split("KEY_U_MASK_SHIFT")
|
||||||
|
.nth(1)?
|
||||||
|
.split(';')
|
||||||
|
.next()?
|
||||||
|
.split('|')
|
||||||
|
.next()?
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
Some(DeviceKey {
|
||||||
|
key: parse_hex16(key_str)?,
|
||||||
|
node: u16::from_str_radix(node_str.trim_start_matches("0x"), 16).ok()?,
|
||||||
|
uv: u32::from_str_radix(uv_str.trim_start_matches("0x"), 16).ok()?,
|
||||||
|
u_mask_shift: u8::from_str_radix(shift_str.trim_start_matches("0x"), 16).ok()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_processing_key(line: &str) -> Option<[u8; 16]> {
|
||||||
|
// | PK | 0x...
|
||||||
|
let parts: Vec<&str> = line.split('|').collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
let key_str = parts[2].split(';').next()?.trim();
|
||||||
|
return parse_hex16(key_str);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_host_cert(line: &str) -> Option<HostCert> {
|
||||||
|
// | HC | HOST_PRIV_KEY 0x... | HOST_CERT 0x...
|
||||||
|
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();
|
||||||
|
|
||||||
|
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(" = ")?;
|
||||||
|
let disc_hash = hash_part.trim().to_lowercase();
|
||||||
|
|
||||||
|
// Extract title (before first |)
|
||||||
|
let title_part = rest.split(" | ").next().unwrap_or("").trim();
|
||||||
|
// Clean title: "TITLE_NAME (Display Title)" → use display title if present
|
||||||
|
let title = if let Some(start) = title_part.find('(') {
|
||||||
|
if let Some(end) = title_part.rfind(')') {
|
||||||
|
title_part[start + 1..end].to_string()
|
||||||
|
} else {
|
||||||
|
title_part.to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
title_part.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse fields by tag
|
||||||
|
let mut media_key = None;
|
||||||
|
let mut disc_id = None;
|
||||||
|
let mut vuk = None;
|
||||||
|
let mut unit_keys = Vec::new();
|
||||||
|
|
||||||
|
let parts: Vec<&str> = rest.split(" | ").collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < parts.len() {
|
||||||
|
match parts[i].trim() {
|
||||||
|
"M" => {
|
||||||
|
if i + 1 < parts.len() {
|
||||||
|
media_key = parse_hex16(parts[i + 1].trim());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"I" => {
|
||||||
|
if i + 1 < parts.len() {
|
||||||
|
disc_id = parse_hex16(parts[i + 1].trim());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"V" => {
|
||||||
|
if i + 1 < parts.len() {
|
||||||
|
vuk = parse_hex16(parts[i + 1].trim());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"U" => {
|
||||||
|
if i + 1 < parts.len() {
|
||||||
|
// Unit keys: "1-0xKEY" or "1-0xKEY ; comment"
|
||||||
|
let uk_str = parts[i + 1].split(';').next().unwrap_or("").trim();
|
||||||
|
for uk in uk_str.split(' ') {
|
||||||
|
let uk = uk.trim();
|
||||||
|
if let Some((num, key)) = uk.split_once('-') {
|
||||||
|
if let Ok(n) = num.parse::<u32>() {
|
||||||
|
if let Some(k) = parse_hex16(key) {
|
||||||
|
unit_keys.push((n, k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(DiscEntry {
|
||||||
|
disc_hash,
|
||||||
|
title,
|
||||||
|
media_key,
|
||||||
|
disc_id,
|
||||||
|
vuk,
|
||||||
|
unit_keys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||||
|
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||||
|
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||||
|
if path.exists() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_disc_entry() {
|
||||||
|
let line = r#"***REMOVED*** = DUNE_PART_TWO (Dune: Part Two) | D | 2024-04-02 | M | ***REMOVED*** | I | ***REMOVED*** | V | ***REMOVED*** | U | 1-***REMOVED*** ; MKBv77"#;
|
||||||
|
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||||
|
assert_eq!(entry.title, "Dune: Part Two");
|
||||||
|
assert!(entry.media_key.is_some());
|
||||||
|
assert!(entry.vuk.is_some());
|
||||||
|
assert_eq!(entry.unit_keys.len(), 1);
|
||||||
|
assert_eq!(entry.unit_keys[0].0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_device_key() {
|
||||||
|
let line = "| DK | DEVICE_KEY ***REMOVED*** | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||||
|
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||||
|
assert_eq!(dk.node, 0x0800);
|
||||||
|
assert_eq!(dk.u_mask_shift, 0x17);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_host_cert() {
|
||||||
|
let line = "| HC | HOST_PRIV_KEY ***REMOVED*** | HOST_CERT ***REMOVED*** ; Revoked";
|
||||||
|
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||||
|
assert_eq!(hc.private_key[0], 0x90);
|
||||||
|
assert_eq!(hc.certificate.len(), 92);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_full_keydb() {
|
||||||
|
let path = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
}; // skip if not available
|
||||||
|
|
||||||
|
let db = KeyDb::load(&path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(db.device_keys.len(), 4);
|
||||||
|
assert_eq!(db.processing_keys.len(), 3);
|
||||||
|
assert!(!db.host_certs.is_empty());
|
||||||
|
assert!(db.disc_entries.len() > 170000);
|
||||||
|
|
||||||
|
// Look up Dune: Part Two
|
||||||
|
let dune = db
|
||||||
|
.disc_entries
|
||||||
|
.values()
|
||||||
|
.find(|e| e.title.contains("Dune: Part Two") && e.vuk.is_some())
|
||||||
|
.expect("Dune: Part Two not found");
|
||||||
|
assert!(dune.media_key.is_some());
|
||||||
|
assert!(dune.vuk.is_some());
|
||||||
|
assert!(!dune.unit_keys.is_empty());
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Parsed {} disc entries, {} DK, {} PK",
|
||||||
|
db.disc_entries.len(),
|
||||||
|
db.device_keys.len(),
|
||||||
|
db.processing_keys.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,945 @@
|
|||||||
|
//! AACS key resolution — VUK derivation, MKB processing, disc hash, unit key parsing.
|
||||||
|
|
||||||
|
use super::decrypt::{aes_ecb_decrypt, aes_ecb_encrypt};
|
||||||
|
use super::keydb::{DeviceKey, KeyDb};
|
||||||
|
|
||||||
|
// ── VUK derivation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Derive VUK from Media Key and Volume ID.
|
||||||
|
/// VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
|
||||||
|
pub fn derive_vuk(media_key: &[u8; 16], volume_id: &[u8; 16]) -> [u8; 16] {
|
||||||
|
let mut vuk = aes_ecb_decrypt(media_key, volume_id);
|
||||||
|
for i in 0..16 {
|
||||||
|
vuk[i] ^= volume_id[i];
|
||||||
|
}
|
||||||
|
vuk
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an encrypted unit key using the VUK (AES-128-ECB).
|
||||||
|
pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||||
|
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unit_Key_RO.inf parsing ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Parsed Unit_Key_RO.inf file.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct UnitKeyFile {
|
||||||
|
/// Disc hash (SHA1 of the entire file) — used as KEYDB lookup key
|
||||||
|
pub disc_hash: [u8; 20],
|
||||||
|
/// Application type (1 = BD-ROM)
|
||||||
|
pub app_type: u8,
|
||||||
|
/// Number of BDMV directories
|
||||||
|
pub num_bdmv_dir: u8,
|
||||||
|
/// Whether SKB MKB is used
|
||||||
|
pub use_skb_mkb: bool,
|
||||||
|
/// Whether this is AACS 2.0
|
||||||
|
pub aacs2: bool,
|
||||||
|
/// Encrypted unit keys (CPS unit number, encrypted key)
|
||||||
|
pub encrypted_keys: Vec<(u32, [u8; 16])>,
|
||||||
|
/// Title → CPS unit index mapping (title_idx → unit_key_idx)
|
||||||
|
pub title_cps_unit: Vec<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute disc hash (SHA1 of Unit_Key_RO.inf content).
|
||||||
|
pub fn disc_hash(data: &[u8]) -> [u8; 20] {
|
||||||
|
use sha1::{Digest, Sha1};
|
||||||
|
let hash = Sha1::digest(data);
|
||||||
|
let mut out = [0u8; 20];
|
||||||
|
out.copy_from_slice(&hash);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format disc hash as hex string with 0x prefix (for KEYDB lookup).
|
||||||
|
pub fn disc_hash_hex(hash: &[u8; 20]) -> String {
|
||||||
|
let mut s = String::with_capacity(42);
|
||||||
|
s.push_str("0x");
|
||||||
|
for b in hash {
|
||||||
|
s.push_str(&format!("{:02X}", b));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse Unit_Key_RO.inf from raw bytes.
|
||||||
|
///
|
||||||
|
/// Format (from AACS spec):
|
||||||
|
/// [0..4] BE32: offset to key storage area (uk_pos)
|
||||||
|
/// [16] app_type (1 = BD-ROM)
|
||||||
|
/// [17] num_bdmv_dir
|
||||||
|
/// [18] bit 7: use_skb_mkb
|
||||||
|
/// [20..22] BE16: first_play CPS unit
|
||||||
|
/// [22..24] BE16: top_menu CPS unit
|
||||||
|
/// [24..26] BE16: num_titles
|
||||||
|
/// [26..] title entries: 2 bytes padding + 2 bytes CPS unit, × num_titles
|
||||||
|
///
|
||||||
|
/// Key storage at uk_pos:
|
||||||
|
/// [uk_pos..uk_pos+2] BE16: num_unit_keys
|
||||||
|
/// [uk_pos+48..] encrypted keys, 16 bytes each
|
||||||
|
/// AACS 1.0: 48-byte stride
|
||||||
|
/// AACS 2.0: 64-byte stride (48 + 16 extra)
|
||||||
|
pub fn parse_unit_key_ro(data: &[u8], aacs2: bool) -> Option<UnitKeyFile> {
|
||||||
|
if data.len() < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = disc_hash(data);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let app_type = data[16];
|
||||||
|
let num_bdmv_dir = data[17];
|
||||||
|
let use_skb_mkb = (data[18] >> 7) & 1 == 1;
|
||||||
|
|
||||||
|
// Key storage offset
|
||||||
|
let uk_pos = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||||
|
if uk_pos + 2 > data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number of unit keys
|
||||||
|
let num_uk = u16::from_be_bytes([data[uk_pos], data[uk_pos + 1]]) as usize;
|
||||||
|
if num_uk == 0 {
|
||||||
|
return Some(UnitKeyFile {
|
||||||
|
disc_hash: hash,
|
||||||
|
app_type,
|
||||||
|
num_bdmv_dir,
|
||||||
|
use_skb_mkb,
|
||||||
|
aacs2,
|
||||||
|
encrypted_keys: Vec::new(),
|
||||||
|
title_cps_unit: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stride between keys
|
||||||
|
let stride = if aacs2 { 64 } else { 48 };
|
||||||
|
|
||||||
|
// Validate size
|
||||||
|
let keys_start = uk_pos + 48; // first key at uk_pos + 48
|
||||||
|
if keys_start + 16 > data.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract encrypted keys
|
||||||
|
let mut encrypted_keys = Vec::with_capacity(num_uk);
|
||||||
|
let mut pos = keys_start;
|
||||||
|
for i in 0..num_uk {
|
||||||
|
if pos + 16 > data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
key.copy_from_slice(&data[pos..pos + 16]);
|
||||||
|
encrypted_keys.push(((i + 1) as u32, key));
|
||||||
|
pos += stride;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title → CPS unit mapping
|
||||||
|
let mut title_cps_unit = Vec::new();
|
||||||
|
if data.len() >= 26 {
|
||||||
|
let first_play = u16::from_be_bytes([data[20], data[21]]);
|
||||||
|
let top_menu = u16::from_be_bytes([data[22], data[23]]);
|
||||||
|
let num_titles = u16::from_be_bytes([data[24], data[25]]) as usize;
|
||||||
|
|
||||||
|
title_cps_unit.push(first_play);
|
||||||
|
title_cps_unit.push(top_menu);
|
||||||
|
|
||||||
|
for i in 0..num_titles {
|
||||||
|
let off = 26 + i * 4 + 2; // 2 bytes padding + 2 bytes CPS unit
|
||||||
|
if off + 2 <= data.len() {
|
||||||
|
let cps = u16::from_be_bytes([data[off], data[off + 1]]);
|
||||||
|
title_cps_unit.push(cps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(UnitKeyFile {
|
||||||
|
disc_hash: hash,
|
||||||
|
app_type,
|
||||||
|
num_bdmv_dir,
|
||||||
|
use_skb_mkb,
|
||||||
|
aacs2,
|
||||||
|
encrypted_keys,
|
||||||
|
title_cps_unit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MKB processing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Derive Media Key from MKB data using processing keys.
|
||||||
|
///
|
||||||
|
/// Processing keys are pre-computed keys that work for specific MKB versions.
|
||||||
|
/// This is the fast path — no subset-difference tree traversal needed.
|
||||||
|
///
|
||||||
|
/// MKB format:
|
||||||
|
/// Record type 0x10 = Verify Media Key Record (has mk_dv)
|
||||||
|
/// Record type 0x81 = Type and Version Record (has MKB version)
|
||||||
|
/// Record type 0x04 = Subset-Difference Index (has UVS entries)
|
||||||
|
/// Record type 0x07 = Explicit Subset-Difference Record (has cvalues)
|
||||||
|
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||||
|
// Parse MKB records
|
||||||
|
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||||
|
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||||
|
let cvalues = mkb_find_cvalues(mkb)?;
|
||||||
|
|
||||||
|
// Count UV entries (each 5 bytes, stop when high bits set)
|
||||||
|
let num_uvs = uvs
|
||||||
|
.chunks(5)
|
||||||
|
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// Try each processing key against each UV/cvalue pair
|
||||||
|
for pk in processing_keys {
|
||||||
|
for i in 0..num_uvs {
|
||||||
|
let uv = &uvs[1 + i * 5..]; // skip first byte
|
||||||
|
let cv = &cvalues[i * 16..(i + 1) * 16];
|
||||||
|
if let Some(mk) = validate_processing_key(pk, cv, uv, &mk_dv) {
|
||||||
|
return Some(mk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a processing key against a cvalue/UV pair.
|
||||||
|
/// Returns the Media Key if valid.
|
||||||
|
fn validate_processing_key(
|
||||||
|
pk: &[u8; 16],
|
||||||
|
cvalue: &[u8],
|
||||||
|
_uv: &[u8],
|
||||||
|
mk_dv: &[u8; 16],
|
||||||
|
) -> Option<[u8; 16]> {
|
||||||
|
if cvalue.len() < 16 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// mk = AES-DEC(pk, cvalue) XOR cvalue
|
||||||
|
let mut cv = [0u8; 16];
|
||||||
|
cv.copy_from_slice(&cvalue[..16]);
|
||||||
|
let mut mk = aes_ecb_decrypt(pk, &cv);
|
||||||
|
for i in 0..16 {
|
||||||
|
mk[i] ^= cv[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify: AES-ECB(mk, mk_dv) should produce a specific pattern
|
||||||
|
let _verify = aes_ecb_encrypt(&mk, mk_dv);
|
||||||
|
// mk_dv verification: the first 12 bytes of AES(mk, mk_dv) should be all 0xDEADBEEF...
|
||||||
|
// Actually per AACS spec: verify record value is AES(mk, all_zeros)
|
||||||
|
// No — the mk_dv IS the verification value. We compute AES-ECB(mk, verify_data)
|
||||||
|
// and check it matches.
|
||||||
|
// From libaacs _validate_pk:
|
||||||
|
// crypto_aes128d(pk, rec + a*16, mk) → decrypt cvalue with PK
|
||||||
|
// mk[i] ^= rec[i] → XOR with cvalue
|
||||||
|
// crypto_aes128e(mk, mk_dv, test) → encrypt mk_dv with derived mk
|
||||||
|
// if first 12 bytes of test are zero → valid media key
|
||||||
|
let test = aes_ecb_encrypt(&mk, mk_dv);
|
||||||
|
// AACS spec: Verify Media Key record — first 12 bytes must be zero
|
||||||
|
if test[..12] == [0u8; 12] {
|
||||||
|
return Some(mk);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find Verify Media Key Record (type 0x10) in MKB.
|
||||||
|
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 4 <= mkb.len() {
|
||||||
|
let rec_type = mkb[pos];
|
||||||
|
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||||
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec_type == 0x10 && rec_len >= 20 {
|
||||||
|
// mk_dv is at offset 4 (after record header)
|
||||||
|
let mut dv = [0u8; 16];
|
||||||
|
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||||
|
return Some(dv);
|
||||||
|
}
|
||||||
|
pos += rec_len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find Subset-Difference records (type 0x04) in MKB.
|
||||||
|
fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 4 <= mkb.len() {
|
||||||
|
let rec_type = mkb[pos];
|
||||||
|
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||||
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec_type == 0x04 && rec_len > 4 {
|
||||||
|
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||||
|
}
|
||||||
|
pos += rec_len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find Conditional Values (cvalues) record (type 0x07) in MKB.
|
||||||
|
fn mkb_find_cvalues(mkb: &[u8]) -> Option<Vec<u8>> {
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 4 <= mkb.len() {
|
||||||
|
let rec_type = mkb[pos];
|
||||||
|
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||||
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec_type == 0x07 && rec_len > 4 {
|
||||||
|
return Some(mkb[pos + 4..pos + rec_len].to_vec());
|
||||||
|
}
|
||||||
|
pos += rec_len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get MKB version from Type and Version Record (type 0x81).
|
||||||
|
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||||
|
let mut pos = 0;
|
||||||
|
while pos + 4 <= mkb.len() {
|
||||||
|
let rec_type = mkb[pos];
|
||||||
|
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||||
|
if rec_len < 4 || pos + rec_len > mkb.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec_type == 0x81 && rec_len >= 8 {
|
||||||
|
return Some(u32::from_be_bytes([
|
||||||
|
mkb[pos + 4],
|
||||||
|
mkb[pos + 5],
|
||||||
|
mkb[pos + 6],
|
||||||
|
mkb[pos + 7],
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
pos += rec_len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AACS-G3 key derivation (subset-difference tree) ─────────────────────────
|
||||||
|
|
||||||
|
/// AACS-G3 seed constant.
|
||||||
|
const AESG3_SEED: [u8; 16] = [
|
||||||
|
0x7B, 0x10, 0x3C, 0x5D, 0xCB, 0x08, 0xC4, 0xE5, 0x1A, 0x27, 0xB0, 0x17, 0x99, 0x05, 0x3B, 0xD9,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// AACS-G3: derive a subkey from a parent key.
|
||||||
|
/// seed[15] += inc, then AES-DEC(key, seed) XOR seed.
|
||||||
|
fn aesg3(key: &[u8; 16], inc: u8) -> [u8; 16] {
|
||||||
|
let mut seed = AESG3_SEED;
|
||||||
|
seed[15] = seed[15].wrapping_add(inc);
|
||||||
|
let mut out = aes_ecb_decrypt(key, &seed);
|
||||||
|
for i in 0..16 {
|
||||||
|
out[i] ^= seed[i];
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute v_mask from a UV value.
|
||||||
|
fn calc_v_mask(uv: u32) -> u32 {
|
||||||
|
let mut v_mask: u32 = 0xFFFFFFFF;
|
||||||
|
while (uv & !v_mask) == 0 && v_mask != 0 {
|
||||||
|
v_mask <<= 1;
|
||||||
|
}
|
||||||
|
v_mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive processing key from device key using subset-difference tree traversal.
|
||||||
|
fn calc_pk_from_dk(dk: &[u8; 16], uv: u32, v_mask: u32, dev_key_v_mask: u32) -> [u8; 16] {
|
||||||
|
// Initial derivation: left_child = aesg3(dk, 0), pk = aesg3(dk, 1), right_child = aesg3(dk, 2)
|
||||||
|
let mut left_child = aesg3(dk, 0);
|
||||||
|
let mut pk = aesg3(dk, 1);
|
||||||
|
let mut right_child = aesg3(dk, 2);
|
||||||
|
let mut current_v_mask = dev_key_v_mask;
|
||||||
|
|
||||||
|
while current_v_mask != v_mask {
|
||||||
|
// Find the highest unset bit in current_v_mask
|
||||||
|
let mut bit_pos: i32 = -1;
|
||||||
|
for i in (0..32).rev() {
|
||||||
|
if (current_v_mask & (1u32 << i)) == 0 {
|
||||||
|
bit_pos = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let curr_key = if bit_pos < 0 || (uv & (1u32 << bit_pos as u32)) == 0 {
|
||||||
|
left_child
|
||||||
|
} else {
|
||||||
|
right_child
|
||||||
|
};
|
||||||
|
|
||||||
|
left_child = aesg3(&curr_key, 0);
|
||||||
|
pk = aesg3(&curr_key, 1);
|
||||||
|
right_child = aesg3(&curr_key, 2);
|
||||||
|
|
||||||
|
current_v_mask = ((current_v_mask as i32) >> 1) as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
pk
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive Media Key from MKB using device keys (subset-difference tree).
|
||||||
|
pub fn derive_media_key_from_dk(mkb: &[u8], device_keys: &[DeviceKey]) -> Option<[u8; 16]> {
|
||||||
|
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||||
|
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||||
|
let cvalues = mkb_find_cvalues(mkb)?;
|
||||||
|
|
||||||
|
// Count UV entries
|
||||||
|
let num_uvs = uvs
|
||||||
|
.chunks(5)
|
||||||
|
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
for dk in device_keys {
|
||||||
|
let device_number = dk.node as u32;
|
||||||
|
|
||||||
|
// Find applying subset-difference for this device
|
||||||
|
for uvs_idx in 0..num_uvs {
|
||||||
|
let p_uv = &uvs[1 + 5 * uvs_idx..];
|
||||||
|
let u_mask_shift = uvs[5 * uvs_idx]; // byte before the UV value
|
||||||
|
|
||||||
|
if u_mask_shift & 0xC0 != 0 {
|
||||||
|
break; // device revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
let uv = u32::from_be_bytes([p_uv[0], p_uv[1], p_uv[2], p_uv[3]]);
|
||||||
|
if uv == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let u_mask: u32 = 0xFFFFFFFF << u_mask_shift;
|
||||||
|
let v_mask = calc_v_mask(uv);
|
||||||
|
|
||||||
|
if ((device_number & u_mask) == (uv & u_mask))
|
||||||
|
&& ((device_number & v_mask) != (uv & v_mask))
|
||||||
|
{
|
||||||
|
// Found matching subset-difference — find the right device key
|
||||||
|
let dev_key_v_mask = calc_v_mask(dk.uv);
|
||||||
|
let dev_key_u_mask: u32 = 0xFFFFFFFF << dk.u_mask_shift;
|
||||||
|
|
||||||
|
if u_mask == dev_key_u_mask && (uv & dev_key_v_mask) == (dk.uv & dev_key_v_mask) {
|
||||||
|
// Derive processing key via tree traversal
|
||||||
|
let pk = calc_pk_from_dk(&dk.key, uv, v_mask, dev_key_v_mask);
|
||||||
|
|
||||||
|
// Validate and derive media key
|
||||||
|
if uvs_idx < cvalues.len() / 16 {
|
||||||
|
let cv = &cvalues[uvs_idx * 16..(uvs_idx + 1) * 16];
|
||||||
|
if let Some(mk) =
|
||||||
|
validate_processing_key(&pk, cv, &uvs[1 + uvs_idx * 5..], &mk_dv)
|
||||||
|
{
|
||||||
|
return Some(mk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MKB disc structure format code.
|
||||||
|
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
|
||||||
|
/// MKB pack buffer size.
|
||||||
|
const MKB_PACK_SIZE: usize = 32772;
|
||||||
|
|
||||||
|
/// Read MKB from drive via SCSI (REPORT DISC STRUCTURE format 0x83).
|
||||||
|
/// Returns the concatenated MKB data from all packs.
|
||||||
|
pub fn read_mkb_from_drive(
|
||||||
|
session: &mut crate::drive::DriveSession,
|
||||||
|
) -> crate::error::Result<Vec<u8>> {
|
||||||
|
use crate::scsi::{DataDirection, SCSI_READ_DISC_STRUCTURE};
|
||||||
|
|
||||||
|
let cdb = [
|
||||||
|
SCSI_READ_DISC_STRUCTURE,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
MKB_DISC_STRUCTURE_FORMAT,
|
||||||
|
(MKB_PACK_SIZE >> 8) as u8,
|
||||||
|
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
];
|
||||||
|
let mut buf = vec![0u8; 32772];
|
||||||
|
session.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)?;
|
||||||
|
|
||||||
|
let data_len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||||
|
if data_len < 2 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let len = data_len - 2;
|
||||||
|
let num_packs = buf[3] as usize;
|
||||||
|
|
||||||
|
let mut mkb = Vec::with_capacity(32768 * num_packs.max(1));
|
||||||
|
if len > 0 && len <= 32768 {
|
||||||
|
mkb.extend_from_slice(&buf[4..4 + len]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read remaining packs
|
||||||
|
for pack in 1..num_packs {
|
||||||
|
let mut cdb = [
|
||||||
|
SCSI_READ_DISC_STRUCTURE,
|
||||||
|
0x01,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
MKB_DISC_STRUCTURE_FORMAT,
|
||||||
|
(MKB_PACK_SIZE >> 8) as u8,
|
||||||
|
(MKB_PACK_SIZE & 0xFF) as u8,
|
||||||
|
0x00,
|
||||||
|
0x00,
|
||||||
|
];
|
||||||
|
// Pack number goes in address field
|
||||||
|
cdb[2] = ((pack >> 24) & 0xFF) as u8;
|
||||||
|
cdb[3] = ((pack >> 16) & 0xFF) as u8;
|
||||||
|
cdb[4] = ((pack >> 8) & 0xFF) as u8;
|
||||||
|
cdb[5] = (pack & 0xFF) as u8;
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; 32772];
|
||||||
|
if session
|
||||||
|
.scsi_execute(&cdb, DataDirection::FromDevice, &mut buf, 10_000)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
let len = u16::from_be_bytes([buf[0], buf[1]]) as usize;
|
||||||
|
if len > 2 && len - 2 <= 32768 {
|
||||||
|
mkb.extend_from_slice(&buf[4..4 + len - 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(mkb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content Certificate parsing ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// AACS Content Certificate — identifies disc AACS version and features.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ContentCert {
|
||||||
|
/// Bus encryption enabled flag
|
||||||
|
pub bus_encryption: bool,
|
||||||
|
/// Content Certificate ID (6 bytes)
|
||||||
|
pub cc_id: [u8; 6],
|
||||||
|
/// AACS version: false = AACS 1.0, true = AACS 2.0
|
||||||
|
pub aacs2: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Content Certificate (ContentXXX.cer) file.
|
||||||
|
pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
|
||||||
|
if data.len() < 8 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content Certificate format:
|
||||||
|
// [0] certificate type (0x00 = AACS1, 0x01 = AACS2)
|
||||||
|
// [1] bus_encryption_enabled (bit 0)
|
||||||
|
// [2..8] cc_id (6 bytes)
|
||||||
|
let aacs2 = data[0] != 0x00;
|
||||||
|
let bus_encryption = (data[1] & 0x01) != 0;
|
||||||
|
let mut cc_id = [0u8; 6];
|
||||||
|
cc_id.copy_from_slice(&data[2..8]);
|
||||||
|
|
||||||
|
Some(ContentCert {
|
||||||
|
bus_encryption,
|
||||||
|
cc_id,
|
||||||
|
aacs2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full VUK resolution chain ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Result of resolving a disc's VUK.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ResolvedKeys {
|
||||||
|
/// Disc hash (SHA1 of Unit_Key_RO.inf)
|
||||||
|
pub disc_hash: [u8; 20],
|
||||||
|
/// Volume Unique Key
|
||||||
|
pub vuk: [u8; 16],
|
||||||
|
/// Decrypted unit keys (CPS unit number, key)
|
||||||
|
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||||
|
/// Title → CPS unit index mapping
|
||||||
|
pub title_cps_unit: Vec<u16>,
|
||||||
|
/// Whether AACS 2.0
|
||||||
|
pub aacs2: bool,
|
||||||
|
/// Whether bus encryption is enabled (from Content Certificate)
|
||||||
|
pub bus_encryption: bool,
|
||||||
|
/// Which resolution path succeeded (1=KEYDB, 2=KEYDB derived, 3=PK, 4=DK)
|
||||||
|
pub key_source: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve all AACS keys for a disc given:
|
||||||
|
/// - Unit_Key_RO.inf raw data
|
||||||
|
/// - Content Certificate raw data (optional, for AACS version detection)
|
||||||
|
/// - Volume ID (from SCSI handshake)
|
||||||
|
/// - KEYDB
|
||||||
|
///
|
||||||
|
/// Tries in order:
|
||||||
|
/// 1. Disc hash → KEYDB → VUK (fast path)
|
||||||
|
/// 2. KEYDB media key + volume ID → VUK (if disc hash not in KEYDB but MK is)
|
||||||
|
/// 3. MKB + processing keys → media key → VUK (full derivation)
|
||||||
|
pub fn resolve_keys(
|
||||||
|
unit_key_ro_data: &[u8],
|
||||||
|
content_cert_data: Option<&[u8]>,
|
||||||
|
volume_id: &[u8; 16],
|
||||||
|
keydb: &KeyDb,
|
||||||
|
mkb_data: Option<&[u8]>,
|
||||||
|
) -> Option<ResolvedKeys> {
|
||||||
|
// Detect AACS version
|
||||||
|
let aacs2 = content_cert_data
|
||||||
|
.and_then(parse_content_cert)
|
||||||
|
.map(|cc| cc.aacs2)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let bus_encryption = content_cert_data
|
||||||
|
.and_then(parse_content_cert)
|
||||||
|
.map(|cc| cc.bus_encryption)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Parse Unit_Key_RO.inf
|
||||||
|
let uk_file = parse_unit_key_ro(unit_key_ro_data, aacs2)?;
|
||||||
|
|
||||||
|
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
|
||||||
|
|
||||||
|
// Helper to build result
|
||||||
|
let build = |vuk: [u8; 16], key_source: u8| -> ResolvedKeys {
|
||||||
|
let unit_keys: Vec<(u32, [u8; 16])> = uk_file
|
||||||
|
.encrypted_keys
|
||||||
|
.iter()
|
||||||
|
.map(|(num, enc_key)| (*num, decrypt_unit_key(&vuk, enc_key)))
|
||||||
|
.collect();
|
||||||
|
ResolvedKeys {
|
||||||
|
disc_hash: uk_file.disc_hash,
|
||||||
|
vuk,
|
||||||
|
unit_keys,
|
||||||
|
title_cps_unit: uk_file.title_cps_unit.clone(),
|
||||||
|
aacs2,
|
||||||
|
bus_encryption,
|
||||||
|
key_source,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Path 1: Look up VUK by disc hash in KEYDB
|
||||||
|
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
||||||
|
if let Some(vuk) = entry.vuk {
|
||||||
|
return Some(build(vuk, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||||
|
for entry in keydb.disc_entries.values() {
|
||||||
|
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||||
|
if did == *volume_id {
|
||||||
|
return Some(build(derive_vuk(&mk, volume_id), 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 3: MKB + processing keys → media key → VUK
|
||||||
|
if let Some(mkb) = mkb_data {
|
||||||
|
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
||||||
|
return Some(build(derive_vuk(&mk, volume_id), 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||||
|
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
||||||
|
return Some(build(derive_vuk(&mk, volume_id), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN};
|
||||||
|
use super::super::keydb::{DiscEntry, KeyDb};
|
||||||
|
|
||||||
|
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||||
|
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||||
|
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||||
|
if path.exists() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vuk_derivation() {
|
||||||
|
// Civil War UHD: known MK, VID, VUK from KEYDB
|
||||||
|
// MK = 15665F98..., VID (disc_id) = from entry, VUK = F96D7908...
|
||||||
|
// VUK = AES-DEC(MK, VID) XOR VID
|
||||||
|
let path = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = KeyDb::load(&path).unwrap();
|
||||||
|
|
||||||
|
// Find a disc with both MK, disc_id, and VUK so we can verify derivation
|
||||||
|
let entry = db
|
||||||
|
.disc_entries
|
||||||
|
.values()
|
||||||
|
.find(|e| e.media_key.is_some() && e.disc_id.is_some() && e.vuk.is_some())
|
||||||
|
.expect("No disc with MK + VID + VUK");
|
||||||
|
|
||||||
|
let mk = entry.media_key.unwrap();
|
||||||
|
let vid = entry.disc_id.unwrap();
|
||||||
|
let expected_vuk = entry.vuk.unwrap();
|
||||||
|
|
||||||
|
let derived = derive_vuk(&mk, &vid);
|
||||||
|
assert_eq!(
|
||||||
|
derived, expected_vuk,
|
||||||
|
"VUK derivation failed for disc: {} (hash {})",
|
||||||
|
entry.title, entry.disc_hash
|
||||||
|
);
|
||||||
|
eprintln!("VUK derivation verified for: {}", entry.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_unit_key_from_vuk() {
|
||||||
|
// Test the full chain: VUK → decrypt encrypted unit key → unit key
|
||||||
|
// Use a known disc from KEYDB that has both VUK and unit keys
|
||||||
|
let path = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let db = KeyDb::load(&path).unwrap();
|
||||||
|
|
||||||
|
// Find a disc with VUK and unit keys
|
||||||
|
let entry = db
|
||||||
|
.disc_entries
|
||||||
|
.values()
|
||||||
|
.find(|e| e.vuk.is_some() && !e.unit_keys.is_empty())
|
||||||
|
.expect("No disc with VUK + unit keys");
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Testing unit key decrypt for: {} ({})",
|
||||||
|
entry.title, entry.disc_hash
|
||||||
|
);
|
||||||
|
eprintln!(" VUK: {:02X?}", entry.vuk.unwrap());
|
||||||
|
for (num, key) in &entry.unit_keys {
|
||||||
|
eprintln!(" Unit key {}: {:02X?}", num, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unit keys in KEYDB are already decrypted — we can verify the chain
|
||||||
|
// by encrypting with VUK and then decrypting
|
||||||
|
let vuk = entry.vuk.unwrap();
|
||||||
|
for (num, expected_uk) in &entry.unit_keys {
|
||||||
|
let encrypted = aes_ecb_encrypt(&vuk, expected_uk);
|
||||||
|
let decrypted = decrypt_unit_key(&vuk, &encrypted);
|
||||||
|
assert_eq!(
|
||||||
|
&decrypted, expected_uk,
|
||||||
|
"Unit key {} roundtrip failed for {}",
|
||||||
|
num, entry.title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
eprintln!(" All {} unit key roundtrips passed", entry.unit_keys.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_decrypt_real_unit() {
|
||||||
|
// Try decrypting a real encrypted aligned unit from Civil War UHD
|
||||||
|
// This disc is AACS 2.0 (BEE) so unit key alone won't work —
|
||||||
|
// we need bus decryption first. But this verifies the pipeline.
|
||||||
|
let unit_path = std::path::Path::new("/tmp/encrypted_unit.bin");
|
||||||
|
if !unit_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let original = std::fs::read(unit_path).unwrap();
|
||||||
|
assert_eq!(original.len(), ALIGNED_UNIT_LEN);
|
||||||
|
assert!(super::super::decrypt::is_unit_encrypted(&original), "Unit should be encrypted");
|
||||||
|
|
||||||
|
let kp = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let db = KeyDb::load(&kp).unwrap();
|
||||||
|
|
||||||
|
// Civil War UHD entries
|
||||||
|
let civil_war_entries: Vec<&DiscEntry> = db
|
||||||
|
.disc_entries
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.title.contains("CIVIL WAR") && !e.unit_keys.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"Found {} Civil War entries with unit keys",
|
||||||
|
civil_war_entries.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try each entry's unit keys
|
||||||
|
for entry in &civil_war_entries {
|
||||||
|
let keys: Vec<[u8; 16]> = entry.unit_keys.iter().map(|(_, k)| *k).collect();
|
||||||
|
let mut unit = original.clone();
|
||||||
|
|
||||||
|
if let Some(idx) = super::super::decrypt::decrypt_unit_try_keys(&mut unit, &keys) {
|
||||||
|
eprintln!(
|
||||||
|
"SUCCESS: Decrypted with entry {} key {}",
|
||||||
|
entry.disc_hash, idx
|
||||||
|
);
|
||||||
|
// Count TS sync bytes
|
||||||
|
let ts = (0..32).filter(|&i| unit[4 + i * 192] == 0x47).count();
|
||||||
|
eprintln!(" TS sync bytes: {}/32", ts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected: none work because this is AACS 2.0 and needs bus decryption first
|
||||||
|
eprintln!("No unit key worked (expected for AACS 2.0 BEE disc — needs read_data_key)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disc_hash() {
|
||||||
|
// SHA1 of a known byte sequence
|
||||||
|
let data = b"test unit key ro inf data";
|
||||||
|
let hash = disc_hash(data);
|
||||||
|
assert_ne!(hash, [0u8; 20]);
|
||||||
|
// Same input → same hash
|
||||||
|
assert_eq!(hash, disc_hash(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disc_hash_hex() {
|
||||||
|
let hash = [
|
||||||
|
***REMOVED***,
|
||||||
|
];
|
||||||
|
let hex = disc_hash_hex(&hash);
|
||||||
|
assert_eq!(hex, "***REMOVED***");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_unit_key_ro_synthetic() {
|
||||||
|
// Build a synthetic Unit_Key_RO.inf
|
||||||
|
// Header: uk_pos at offset 0 (BE32), points to key storage
|
||||||
|
// Keys at uk_pos + 48 (16 bytes each, 48-byte stride for AACS 1.0)
|
||||||
|
let mut data = vec![0u8; 256];
|
||||||
|
|
||||||
|
// uk_pos = 0x60 (96)
|
||||||
|
data[0] = 0x00;
|
||||||
|
data[1] = 0x00;
|
||||||
|
data[2] = 0x00;
|
||||||
|
data[3] = 0x60;
|
||||||
|
|
||||||
|
// Header fields at 16-18
|
||||||
|
data[16] = 1; // app_type = BD-ROM
|
||||||
|
data[17] = 1; // num_bdmv_dir
|
||||||
|
data[18] = 0; // no SKB
|
||||||
|
|
||||||
|
// Title mapping at 20-25
|
||||||
|
data[20] = 0;
|
||||||
|
data[21] = 1; // first_play = CPS unit 1
|
||||||
|
data[22] = 0;
|
||||||
|
data[23] = 1; // top_menu = CPS unit 1
|
||||||
|
data[24] = 0;
|
||||||
|
data[25] = 1; // num_titles = 1
|
||||||
|
// Title 0 entry: 2 bytes pad + CPS unit
|
||||||
|
data[28] = 0;
|
||||||
|
data[29] = 1; // CPS unit 1
|
||||||
|
|
||||||
|
// Key storage at offset 0x60
|
||||||
|
let uk_pos = 0x60usize;
|
||||||
|
data[uk_pos] = 0;
|
||||||
|
data[uk_pos + 1] = 2; // 2 unit keys
|
||||||
|
|
||||||
|
// Key 1 at uk_pos + 48
|
||||||
|
let key1_pos = uk_pos + 48;
|
||||||
|
for i in 0..16 {
|
||||||
|
data[key1_pos + i] = 0xAA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key 2 at uk_pos + 48 + 48
|
||||||
|
let key2_pos = key1_pos + 48;
|
||||||
|
for i in 0..16 {
|
||||||
|
data[key2_pos + i] = 0xBB;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = parse_unit_key_ro(&data, false).unwrap();
|
||||||
|
assert_eq!(parsed.app_type, 1);
|
||||||
|
assert_eq!(parsed.num_bdmv_dir, 1);
|
||||||
|
assert!(!parsed.aacs2);
|
||||||
|
assert_eq!(parsed.encrypted_keys.len(), 2);
|
||||||
|
assert_eq!(parsed.encrypted_keys[0].0, 1); // CPS unit 1
|
||||||
|
assert_eq!(parsed.encrypted_keys[0].1, [0xAA; 16]);
|
||||||
|
assert_eq!(parsed.encrypted_keys[1].0, 2); // CPS unit 2
|
||||||
|
assert_eq!(parsed.encrypted_keys[1].1, [0xBB; 16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mkb_version_parse() {
|
||||||
|
// Synthetic MKB with Type and Version record (0x81)
|
||||||
|
let mut mkb = vec![0u8; 32];
|
||||||
|
// Record: type=0x81, length=12 (BE24)
|
||||||
|
mkb[0] = 0x81;
|
||||||
|
mkb[1] = 0x00;
|
||||||
|
mkb[2] = 0x00;
|
||||||
|
mkb[3] = 0x0C;
|
||||||
|
// Version = 77
|
||||||
|
mkb[4] = 0x00;
|
||||||
|
mkb[5] = 0x00;
|
||||||
|
mkb[6] = 0x00;
|
||||||
|
mkb[7] = 77;
|
||||||
|
|
||||||
|
assert_eq!(mkb_version(&mkb), Some(77));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_keys_vuk_path() {
|
||||||
|
// Test the full resolve chain using VUK path
|
||||||
|
let path = match keydb_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let db = KeyDb::load(&path).unwrap();
|
||||||
|
|
||||||
|
// Find V for Vendetta BD — has VUK and unit keys
|
||||||
|
// hash: ***REMOVED***
|
||||||
|
let entry = db.find_disc("***REMOVED***");
|
||||||
|
if entry.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let vuk = entry.vuk.unwrap();
|
||||||
|
let vid = entry.disc_id.unwrap();
|
||||||
|
|
||||||
|
// We need the actual Unit_Key_RO.inf from the disc to compute disc hash.
|
||||||
|
// Since we don't have it, we can at least test that the KEYDB lookup
|
||||||
|
// works with a known hash.
|
||||||
|
let hash_hex = "***REMOVED***";
|
||||||
|
let found = db.find_disc(hash_hex);
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().vuk, Some(vuk));
|
||||||
|
|
||||||
|
// Verify VUK derivation if we have MK + VID
|
||||||
|
if let Some(mk) = entry.media_key {
|
||||||
|
let derived = derive_vuk(&mk, &vid);
|
||||||
|
assert_eq!(derived, vuk, "VUK derivation mismatch for V for Vendetta");
|
||||||
|
eprintln!("V for Vendetta VUK derivation verified");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_content_cert_parse() {
|
||||||
|
// AACS 1.0 cert
|
||||||
|
let mut data = vec![0u8; 16];
|
||||||
|
data[0] = 0x00; // AACS 1.0
|
||||||
|
data[1] = 0x00; // no bus encryption
|
||||||
|
let cc = parse_content_cert(&data).unwrap();
|
||||||
|
assert!(!cc.aacs2);
|
||||||
|
assert!(!cc.bus_encryption);
|
||||||
|
|
||||||
|
// AACS 2.0 with bus encryption
|
||||||
|
data[0] = 0x01; // AACS 2.0
|
||||||
|
data[1] = 0x01; // bus encryption enabled
|
||||||
|
let cc = parse_content_cert(&data).unwrap();
|
||||||
|
assert!(cc.aacs2);
|
||||||
|
assert!(cc.bus_encryption);
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1643
File diff suppressed because it is too large
Load Diff
+8
-431
@@ -8,15 +8,18 @@
|
|||||||
//! for title in disc.titles() { ... }
|
//! for title in disc.titles() { ... }
|
||||||
//! for stream in title.streams() { ... }
|
//! for stream in title.streams() { ... }
|
||||||
|
|
||||||
use crate::clpi;
|
mod bluray;
|
||||||
|
mod dvd;
|
||||||
|
mod encrypt;
|
||||||
|
|
||||||
use crate::drive::DriveSession;
|
use crate::drive::DriveSession;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::ifo;
|
|
||||||
use crate::mpls;
|
|
||||||
use crate::sector::SectorReader;
|
use crate::sector::SectorReader;
|
||||||
use crate::speed::DriveSpeed;
|
use crate::speed::DriveSpeed;
|
||||||
use crate::udf;
|
use crate::udf;
|
||||||
|
|
||||||
|
use encrypt::HandshakeResult;
|
||||||
|
|
||||||
// ─── Public types ───────────────────────────────────────────────────────────
|
// ─── Public types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A scanned Blu-ray disc.
|
/// A scanned Blu-ray disc.
|
||||||
@@ -207,6 +210,7 @@ pub enum Codec {
|
|||||||
Lpcm,
|
Lpcm,
|
||||||
// Subtitle
|
// Subtitle
|
||||||
Pgs,
|
Pgs,
|
||||||
|
DvdSub,
|
||||||
// Unknown
|
// Unknown
|
||||||
Unknown(u8),
|
Unknown(u8),
|
||||||
}
|
}
|
||||||
@@ -251,6 +255,7 @@ impl Codec {
|
|||||||
Codec::Ac3Plus => "AC-3+",
|
Codec::Ac3Plus => "AC-3+",
|
||||||
Codec::Lpcm => "LPCM",
|
Codec::Lpcm => "LPCM",
|
||||||
Codec::Pgs => "PGS",
|
Codec::Pgs => "PGS",
|
||||||
|
Codec::DvdSub => "DVD Subtitle",
|
||||||
Codec::Unknown(_) => "Unknown",
|
Codec::Unknown(_) => "Unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,15 +335,6 @@ impl DiscTitle {
|
|||||||
|
|
||||||
// ─── Encryption ─────────────────────────────────────────────────────────────
|
// ─── Encryption ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
|
||||||
/// Only available when scanning from a real drive, not ISO images.
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct HandshakeResult {
|
|
||||||
volume_id: [u8; 16],
|
|
||||||
read_data_key: Option<[u8; 16]>,
|
|
||||||
error: Option<crate::error::Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AACS decryption state for a disc.
|
/// AACS decryption state for a disc.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AacsState {
|
pub struct AacsState {
|
||||||
@@ -612,113 +608,6 @@ impl Disc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SCSI handshake result — volume ID and bus keys from ECDH authentication.
|
|
||||||
/// Only available when scanning from a real drive (not ISO images).
|
|
||||||
fn do_handshake(session: &mut DriveSession, opts: &ScanOptions) -> Option<HandshakeResult> {
|
|
||||||
use crate::aacs::{self, KeyDb};
|
|
||||||
|
|
||||||
let keydb_path = opts.resolve_keydb()?;
|
|
||||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
|
||||||
|
|
||||||
let mut last_error = None;
|
|
||||||
for hc in &keydb.host_certs {
|
|
||||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
|
||||||
Ok(mut auth) => {
|
|
||||||
let volume_id =
|
|
||||||
aacs::handshake::read_volume_id(session, &mut auth).unwrap_or([0u8; 16]);
|
|
||||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
|
||||||
.ok()
|
|
||||||
.map(|(rdk, _)| rdk);
|
|
||||||
return Some(HandshakeResult {
|
|
||||||
volume_id,
|
|
||||||
read_data_key,
|
|
||||||
error: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// Try next host cert
|
|
||||||
last_error = Some(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last_error.map(|e| HandshakeResult {
|
|
||||||
volume_id: [0u8; 16],
|
|
||||||
read_data_key: None,
|
|
||||||
error: Some(e),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
|
||||||
///
|
|
||||||
/// Reads AACS files from UDF (via SectorReader), resolves keys through
|
|
||||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
|
||||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
|
||||||
fn resolve_encryption(
|
|
||||||
udf_fs: &udf::UdfFs,
|
|
||||||
reader: &mut dyn SectorReader,
|
|
||||||
keydb_path: &std::path::Path,
|
|
||||||
handshake: Option<&HandshakeResult>,
|
|
||||||
) -> Result<AacsState> {
|
|
||||||
use crate::aacs::{self, KeyDb};
|
|
||||||
|
|
||||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
|
||||||
path: keydb_path.display().to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Read AACS files from disc/image via UDF
|
|
||||||
let uk_ro_data = udf_fs
|
|
||||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
|
||||||
.map_err(|_| Error::AacsNoKeys)?;
|
|
||||||
|
|
||||||
let cc_data = udf_fs
|
|
||||||
.read_file(reader, "/AACS/Content000.cer")
|
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let mkb_data = udf_fs
|
|
||||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
|
||||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
|
||||||
.ok();
|
|
||||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
|
||||||
|
|
||||||
// Use handshake volume ID if available, otherwise zeros
|
|
||||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
|
||||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
|
||||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
|
||||||
let handshake_error = None;
|
|
||||||
|
|
||||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key
|
|
||||||
let resolved = aacs::resolve_keys(
|
|
||||||
&uk_ro_data,
|
|
||||||
cc_data.as_deref(),
|
|
||||||
&volume_id,
|
|
||||||
&keydb,
|
|
||||||
mkb_data.as_deref(),
|
|
||||||
)
|
|
||||||
.ok_or(Error::AacsNoKeys)?;
|
|
||||||
|
|
||||||
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: 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,
|
|
||||||
handshake_error,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal helpers ────────────────────────────────────────────────────
|
// ── Internal helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Detect disc format from the main title's video streams.
|
/// Detect disc format from the main title's video streams.
|
||||||
@@ -741,48 +630,6 @@ impl Disc {
|
|||||||
DiscFormat::Unknown
|
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(reader: &mut dyn SectorReader, 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(reader, &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> {
|
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||||
let cdb = [
|
let cdb = [
|
||||||
crate::scsi::SCSI_READ_CAPACITY,
|
crate::scsi::SCSI_READ_CAPACITY,
|
||||||
@@ -807,276 +654,6 @@ impl Disc {
|
|||||||
Ok(lba + 1)
|
Ok(lba + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_playlist(
|
|
||||||
reader: &mut dyn SectorReader,
|
|
||||||
udf_fs: &udf::UdfFs,
|
|
||||||
filename: &str,
|
|
||||||
data: &[u8],
|
|
||||||
) -> Option<DiscTitle> {
|
|
||||||
let parsed = mpls::parse(data).ok()?;
|
|
||||||
|
|
||||||
// Calculate duration from play items
|
|
||||||
let duration_ticks: u64 = parsed
|
|
||||||
.play_items
|
|
||||||
.iter()
|
|
||||||
.map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64)
|
|
||||||
.sum();
|
|
||||||
let duration_secs = duration_ticks as f64 / 45000.0;
|
|
||||||
|
|
||||||
// Skip very short playlists (< 30 seconds)
|
|
||||||
if duration_secs < 30.0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse each clip for size, duration, and sector extents
|
|
||||||
let mut extents = Vec::new();
|
|
||||||
let mut total_size: u64 = 0;
|
|
||||||
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(reader, &clpi_path) {
|
|
||||||
if let Ok(clip_info) = clpi::parse(&clpi_data) {
|
|
||||||
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(reader, &m2ts_path).unwrap_or(0);
|
|
||||||
let total_bytes = pkt_count as u64 * 192;
|
|
||||||
let total_sectors = total_bytes.div_ceil(2048) as u32;
|
|
||||||
if total_sectors > 0 && file_lba > 0 {
|
|
||||||
extents.push(Extent {
|
|
||||||
start_lba: file_lba,
|
|
||||||
sector_count: total_sectors,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
.filter_map(|s| {
|
|
||||||
// Skip empty/padding entries (coding_type 0x00)
|
|
||||||
if s.coding_type == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let codec = Codec::from_coding_type(s.coding_type);
|
|
||||||
match s.stream_type {
|
|
||||||
1 | 6 | 7 => Some(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 => {
|
|
||||||
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
|
|
||||||
// this is a misaligned stream -- treat as subtitle, not audio
|
|
||||||
if matches!(codec, Codec::Pgs) {
|
|
||||||
Some(Stream::Subtitle(SubtitleStream {
|
|
||||||
pid: s.pid,
|
|
||||||
codec,
|
|
||||||
language: s.language.clone(),
|
|
||||||
forced: false,
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
Some(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 => Some(Stream::Subtitle(SubtitleStream {
|
|
||||||
pid: s.pid,
|
|
||||||
codec,
|
|
||||||
language: s.language.clone(),
|
|
||||||
forced: false,
|
|
||||||
})),
|
|
||||||
// Stream type 4 = IG, unknown types -- skip
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
|
|
||||||
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
|
|
||||||
|
|
||||||
Some(DiscTitle {
|
|
||||||
playlist: filename.to_string(),
|
|
||||||
playlist_id,
|
|
||||||
duration_secs,
|
|
||||||
size_bytes: total_size,
|
|
||||||
clips,
|
|
||||||
streams,
|
|
||||||
extents,
|
|
||||||
content_format: ContentFormat::BdTs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan Blu-ray titles from MPLS playlists.
|
|
||||||
fn scan_bluray_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> {
|
|
||||||
let mut titles = Vec::new();
|
|
||||||
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
|
|
||||||
for entry in &playlist_dir.entries {
|
|
||||||
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
|
|
||||||
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
|
|
||||||
if let Ok(mpls_data) = udf_fs.read_file(reader, &path) {
|
|
||||||
if let Some(title) =
|
|
||||||
Self::parse_playlist(reader, udf_fs, &entry.name, &mpls_data)
|
|
||||||
{
|
|
||||||
titles.push(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
titles
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
|
||||||
fn scan_dvd_titles(reader: &mut dyn SectorReader, udf_fs: &udf::UdfFs) -> Vec<DiscTitle> {
|
|
||||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
|
||||||
Ok(info) => info,
|
|
||||||
Err(_) => return Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut titles = Vec::new();
|
|
||||||
let mut title_number: u16 = 0;
|
|
||||||
|
|
||||||
for ts in &dvd_info.title_sets {
|
|
||||||
// Map DvdVideoAttr to Stream::Video
|
|
||||||
let video_codec = match ts.video.codec.as_str() {
|
|
||||||
"mpeg2" => Codec::Mpeg2,
|
|
||||||
"mpeg1" => Codec::Mpeg2, // treat MPEG-1 as MPEG-2 for container purposes
|
|
||||||
_ => Codec::Mpeg2,
|
|
||||||
};
|
|
||||||
|
|
||||||
let video_stream = Stream::Video(VideoStream {
|
|
||||||
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
|
|
||||||
codec: video_codec,
|
|
||||||
resolution: ts.video.resolution.clone(),
|
|
||||||
frame_rate: match ts.video.standard.as_str() {
|
|
||||||
"PAL" => "25".to_string(),
|
|
||||||
_ => "29.97".to_string(),
|
|
||||||
},
|
|
||||||
hdr: HdrFormat::Sdr,
|
|
||||||
color_space: ColorSpace::Bt709,
|
|
||||||
secondary: false,
|
|
||||||
label: String::new(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map DvdAudioAttr to Stream::Audio
|
|
||||||
let audio_streams: Vec<Stream> = ts
|
|
||||||
.audio_streams
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, a)| {
|
|
||||||
let codec = match a.codec.as_str() {
|
|
||||||
"ac3" => Codec::Ac3,
|
|
||||||
"dts" => Codec::Dts,
|
|
||||||
"lpcm" => Codec::Lpcm,
|
|
||||||
"mpeg1" | "mpeg2" => Codec::Mpeg2,
|
|
||||||
_ => Codec::Unknown(0),
|
|
||||||
};
|
|
||||||
let channels = match a.channels {
|
|
||||||
1 => "mono".to_string(),
|
|
||||||
2 => "stereo".to_string(),
|
|
||||||
6 => "5.1".to_string(),
|
|
||||||
8 => "7.1".to_string(),
|
|
||||||
n => format!("{}ch", n),
|
|
||||||
};
|
|
||||||
let sample_rate = match a.sample_rate {
|
|
||||||
48000 => "48kHz".to_string(),
|
|
||||||
96000 => "96kHz".to_string(),
|
|
||||||
sr => format!("{}kHz", sr / 1000),
|
|
||||||
};
|
|
||||||
Stream::Audio(AudioStream {
|
|
||||||
pid: 0xBD00 + i as u16, // DVD private stream 1 sub-IDs
|
|
||||||
codec,
|
|
||||||
channels,
|
|
||||||
language: a.language.clone(),
|
|
||||||
sample_rate,
|
|
||||||
secondary: false,
|
|
||||||
label: String::new(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for dvd_title in &ts.titles {
|
|
||||||
title_number += 1;
|
|
||||||
|
|
||||||
// Build extents from cell sector ranges (absolute = vob_start + cell offset)
|
|
||||||
let extents: Vec<Extent> = dvd_title
|
|
||||||
.cells
|
|
||||||
.iter()
|
|
||||||
.map(|cell| {
|
|
||||||
let start = ts.vob_start_sector + cell.first_sector;
|
|
||||||
let count = cell.last_sector.saturating_sub(cell.first_sector) + 1;
|
|
||||||
Extent {
|
|
||||||
start_lba: start,
|
|
||||||
sector_count: count,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let size_bytes: u64 = extents
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.sector_count as u64 * 2048)
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
let mut streams = vec![video_stream.clone()];
|
|
||||||
streams.extend(audio_streams.iter().cloned());
|
|
||||||
|
|
||||||
titles.push(DiscTitle {
|
|
||||||
playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number),
|
|
||||||
playlist_id: title_number,
|
|
||||||
duration_secs: dvd_title.duration_secs,
|
|
||||||
size_bytes,
|
|
||||||
clips: Vec::new(),
|
|
||||||
streams,
|
|
||||||
extents,
|
|
||||||
content_format: ContentFormat::MpegPs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
titles
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Decrypted reader ──────────────────────────────────────────────────────
|
// ─── Decrypted reader ──────────────────────────────────────────────────────
|
||||||
|
|||||||
+197
-18
@@ -3,6 +3,9 @@
|
|||||||
//! AC3 frames are self-contained and always start with syncword 0x0B77.
|
//! AC3 frames are self-contained and always start with syncword 0x0B77.
|
||||||
//! Each PES packet typically contains exactly one AC3 frame.
|
//! Each PES packet typically contains exactly one AC3 frame.
|
||||||
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
|
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
|
||||||
|
//!
|
||||||
|
//! E-AC-3 shares the same syncword but uses bsid >= 11 (typically 16).
|
||||||
|
//! Frame size is derived from the frmsiz field instead of fscod/frmsizecod.
|
||||||
|
|
||||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||||
|
|
||||||
@@ -28,15 +31,67 @@ impl CodecParser for Ac3Parser {
|
|||||||
|
|
||||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||||
|
|
||||||
// Find AC3 syncword (0x0B77) — skip any garbage before it
|
|
||||||
let data = &pes.data;
|
let data = &pes.data;
|
||||||
let start = find_ac3_sync(data).unwrap_or(0);
|
let mut frames = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
|
||||||
vec![Frame {
|
while pos < data.len() {
|
||||||
pts_ns,
|
let sync = find_ac3_sync(&data[pos..]);
|
||||||
keyframe: true,
|
let start = match sync {
|
||||||
data: data[start..].to_vec(),
|
Some(offset) => pos + offset,
|
||||||
}]
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
let remaining = &data[start..];
|
||||||
|
|
||||||
|
// Need at least 6 bytes to inspect bsid / frame size fields
|
||||||
|
if remaining.len() < 6 {
|
||||||
|
// Emit whatever remains as a single frame
|
||||||
|
frames.push(Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: remaining.to_vec(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bsid = get_bsid(remaining);
|
||||||
|
|
||||||
|
if bsid >= 11 {
|
||||||
|
// E-AC-3 frame size from frmsiz field (bytes 2-3)
|
||||||
|
let frame_size = eac3_frame_size(remaining);
|
||||||
|
|
||||||
|
let end = start + frame_size.min(data.len() - start);
|
||||||
|
frames.push(Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: data[start..end].to_vec(),
|
||||||
|
});
|
||||||
|
pos = end;
|
||||||
|
} else {
|
||||||
|
// AC-3: emit everything from syncword to next syncword (or end)
|
||||||
|
let next_sync = find_ac3_sync(&data[start + 2..]).map(|o| start + 2 + o);
|
||||||
|
let end = next_sync.unwrap_or(data.len());
|
||||||
|
frames.push(Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: data[start..end].to_vec(),
|
||||||
|
});
|
||||||
|
pos = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no syncword at all, emit the whole PES as a frame
|
||||||
|
// (backwards-compatible with old behaviour).
|
||||||
|
if frames.is_empty() {
|
||||||
|
frames.push(Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: data.to_vec(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
frames
|
||||||
}
|
}
|
||||||
|
|
||||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
@@ -44,7 +99,7 @@ impl CodecParser for Ac3Parser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find AC3 syncword (0x0B77) in data.
|
/// Find AC3/E-AC-3 syncword (0x0B77) in data.
|
||||||
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||||
for i in 0..data.len().saturating_sub(1) {
|
for i in 0..data.len().saturating_sub(1) {
|
||||||
if data[i] == 0x0B && data[i + 1] == 0x77 {
|
if data[i] == 0x0B && data[i + 1] == 0x77 {
|
||||||
@@ -54,6 +109,23 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract bsid from an AC-3/E-AC-3 frame starting at the syncword.
|
||||||
|
/// bsid is at byte 5, bits 7..3.
|
||||||
|
/// AC-3: bsid <= 10, E-AC-3: bsid >= 11 (typically 16).
|
||||||
|
pub fn get_bsid(data: &[u8]) -> u8 {
|
||||||
|
debug_assert!(data.len() >= 6);
|
||||||
|
(data[5] >> 3) & 0x1F
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate E-AC-3 frame size in bytes from the frmsiz field.
|
||||||
|
/// frmsiz is at bits [2:0] of byte 2 concatenated with byte 3.
|
||||||
|
/// Frame size = (frmsiz + 1) * 2 bytes.
|
||||||
|
pub fn eac3_frame_size(data: &[u8]) -> usize {
|
||||||
|
debug_assert!(data.len() >= 4);
|
||||||
|
let frmsiz = ((data[2] as usize & 0x07) << 8) | (data[3] as usize);
|
||||||
|
(frmsiz + 1) * 2
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -68,6 +140,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a minimal AC-3 header (bsid <= 10).
|
||||||
|
fn make_ac3_header(bsid: u8) -> Vec<u8> {
|
||||||
|
// 0x0B 0x77 <byte2> <byte3> <byte4> <byte5=bsid>
|
||||||
|
let byte5 = (bsid & 0x1F) << 3;
|
||||||
|
vec![0x0B, 0x77, 0x00, 0x00, 0x00, byte5, 0xAA, 0xBB]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal E-AC-3 header with the given bsid and frmsiz.
|
||||||
|
/// frmsiz encodes frame size: frame_bytes = (frmsiz + 1) * 2.
|
||||||
|
fn make_eac3_header(bsid: u8, frmsiz: u16, payload_fill: u8) -> Vec<u8> {
|
||||||
|
let byte2 = (frmsiz >> 8) as u8 & 0x07;
|
||||||
|
let byte3 = (frmsiz & 0xFF) as u8;
|
||||||
|
let byte5 = (bsid & 0x1F) << 3;
|
||||||
|
let frame_size = (frmsiz as usize + 1) * 2;
|
||||||
|
let mut data = vec![0x0B, 0x77, byte2, byte3, 0x00, byte5];
|
||||||
|
// Pad to full frame size
|
||||||
|
while data.len() < frame_size {
|
||||||
|
data.push(payload_fill);
|
||||||
|
}
|
||||||
|
data.truncate(frame_size);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
// --- syncword detection ---
|
// --- syncword detection ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -94,14 +189,98 @@ mod tests {
|
|||||||
assert_eq!(find_ac3_sync(&data), None);
|
assert_eq!(find_ac3_sync(&data), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- parse syncword → frame extracted ---
|
// --- bsid detection ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsid_ac3() {
|
||||||
|
let header = make_ac3_header(8);
|
||||||
|
assert_eq!(get_bsid(&header), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsid_eac3() {
|
||||||
|
let header = make_eac3_header(16, 99, 0x00);
|
||||||
|
assert_eq!(get_bsid(&header), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsid_boundary_10() {
|
||||||
|
let header = make_ac3_header(10);
|
||||||
|
assert_eq!(get_bsid(&header), 10);
|
||||||
|
// bsid 10 should be treated as AC-3 (<= 10)
|
||||||
|
assert!(get_bsid(&header) <= 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bsid_boundary_11() {
|
||||||
|
let header = make_eac3_header(11, 3, 0x00);
|
||||||
|
assert_eq!(get_bsid(&header), 11);
|
||||||
|
// bsid 11 should be treated as E-AC-3 (>= 11)
|
||||||
|
assert!(get_bsid(&header) >= 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- E-AC-3 frame size calculation ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eac3_frame_size_basic() {
|
||||||
|
// frmsiz = 99 → frame_size = (99+1)*2 = 200 bytes
|
||||||
|
let header = make_eac3_header(16, 99, 0xDD);
|
||||||
|
assert_eq!(eac3_frame_size(&header), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eac3_frame_size_min() {
|
||||||
|
// frmsiz = 0 → frame_size = (0+1)*2 = 2 bytes
|
||||||
|
let data = [0x0B, 0x77, 0x00, 0x00, 0x00, 0x80];
|
||||||
|
assert_eq!(eac3_frame_size(&data), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eac3_frame_size_large() {
|
||||||
|
// frmsiz = 0x7FF (max 11-bit) → (2047+1)*2 = 4096
|
||||||
|
let data = [0x0B, 0x77, 0x07, 0xFF, 0x00, 0x80];
|
||||||
|
assert_eq!(eac3_frame_size(&data), 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse: E-AC-3 frame extraction ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_eac3_single_frame() {
|
||||||
|
let mut parser = Ac3Parser::new();
|
||||||
|
// frmsiz = 9 → frame_size = 20 bytes
|
||||||
|
let data = make_eac3_header(16, 9, 0xCC);
|
||||||
|
assert_eq!(data.len(), 20);
|
||||||
|
let pes = make_pes(data.clone(), Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].data.len(), 20);
|
||||||
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||||
|
assert!(frames[0].keyframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_eac3_frame_with_garbage_prefix() {
|
||||||
|
let mut parser = Ac3Parser::new();
|
||||||
|
let mut data = vec![0xFF, 0xFE]; // garbage
|
||||||
|
data.extend_from_slice(&make_eac3_header(16, 4, 0xAA)); // frmsiz=4 → 10 bytes
|
||||||
|
let pes = make_pes(data, Some(0));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].data[0], 0x0B);
|
||||||
|
assert_eq!(frames[0].data[1], 0x77);
|
||||||
|
assert_eq!(frames[0].data.len(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse syncword → frame extracted (AC-3) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_syncword() {
|
fn parse_syncword() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
|
|
||||||
// AC3 frame starting with syncword
|
// AC3 frame starting with syncword (bsid=8)
|
||||||
let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88];
|
let data = make_ac3_header(8);
|
||||||
let pes = make_pes(data.clone(), Some(90000));
|
let pes = make_pes(data.clone(), Some(90000));
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
@@ -115,15 +294,14 @@ mod tests {
|
|||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
|
|
||||||
// Garbage bytes before syncword
|
// Garbage bytes before syncword
|
||||||
let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55];
|
let mut data = vec![0xFF, 0xFE];
|
||||||
|
data.extend_from_slice(&make_ac3_header(8));
|
||||||
let pes = make_pes(data, Some(0));
|
let pes = make_pes(data, Some(0));
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
// Data should start from the syncword
|
|
||||||
assert_eq!(frames[0].data[0], 0x0B);
|
assert_eq!(frames[0].data[0], 0x0B);
|
||||||
assert_eq!(frames[0].data[1], 0x77);
|
assert_eq!(frames[0].data[1], 0x77);
|
||||||
assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- all frames are keyframes ---
|
// --- all frames are keyframes ---
|
||||||
@@ -132,8 +310,9 @@ mod tests {
|
|||||||
fn all_keyframes() {
|
fn all_keyframes() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
|
|
||||||
for i in 0..5 {
|
for i in 0..5u8 {
|
||||||
let data = vec![0x0B, 0x77, 0x00, i];
|
let mut data = make_ac3_header(8);
|
||||||
|
data.push(i);
|
||||||
let pes = make_pes(data, Some(90000 * i as i64));
|
let pes = make_pes(data, Some(90000 * i as i64));
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -172,7 +351,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pts_conversion() {
|
fn pts_conversion() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
let data = make_ac3_header(8);
|
||||||
// 45000 ticks = 0.5 seconds → 500_000_000 ns
|
// 45000 ticks = 0.5 seconds → 500_000_000 ns
|
||||||
let pes = make_pes(data, Some(45000));
|
let pes = make_pes(data, Some(45000));
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
@@ -185,7 +364,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn no_pts() {
|
fn no_pts() {
|
||||||
let mut parser = Ac3Parser::new();
|
let mut parser = Ac3Parser::new();
|
||||||
let data = vec![0x0B, 0x77, 0x00, 0x01];
|
let data = make_ac3_header(8);
|
||||||
let pes = make_pes(data, None);
|
let pes = make_pes(data, None);
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
|
|||||||
+184
-3
@@ -1,12 +1,16 @@
|
|||||||
//! DTS / DTS-HD elementary stream parser.
|
//! DTS / DTS-HD elementary stream parser.
|
||||||
//!
|
//!
|
||||||
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
||||||
//! DTS-HD MA/HRA extension follows the core frame.
|
//! DTS-HD MA/HRA extension syncword: 0x64582025 (32 bits), appears after the core frame.
|
||||||
|
//! The extension contains high-resolution audio data and is appended to the core frame.
|
||||||
//! All frames are keyframes (no inter-frame dependencies).
|
//! All frames are keyframes (no inter-frame dependencies).
|
||||||
//! Each PES packet = one frame.
|
//! Each PES packet = one frame.
|
||||||
|
|
||||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||||
|
|
||||||
|
/// DTS-HD extension syncword bytes.
|
||||||
|
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||||
|
|
||||||
pub struct DtsParser;
|
pub struct DtsParser;
|
||||||
|
|
||||||
impl Default for DtsParser {
|
impl Default for DtsParser {
|
||||||
@@ -27,10 +31,31 @@ impl CodecParser for DtsParser {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||||
|
|
||||||
|
let data = &pes.data;
|
||||||
|
|
||||||
|
// Look for a DTS-HD extension substream after the core.
|
||||||
|
// If found, include both core + extension in the output frame.
|
||||||
|
let frame_data = match find_dts_hd_ext_sync(data) {
|
||||||
|
Some(ext_offset) => {
|
||||||
|
let ext = &data[ext_offset..];
|
||||||
|
if ext.len() >= 9 {
|
||||||
|
let ext_size = dts_hd_ext_frame_size(ext);
|
||||||
|
let total_end = ext_offset + ext_size;
|
||||||
|
let end = total_end.min(data.len());
|
||||||
|
data[..end].to_vec()
|
||||||
|
} else {
|
||||||
|
// Extension header too short to parse size; include all data.
|
||||||
|
data.to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => data.to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
vec![Frame {
|
vec![Frame {
|
||||||
pts_ns,
|
pts_ns,
|
||||||
keyframe: true,
|
keyframe: true,
|
||||||
data: pes.data.clone(),
|
data: frame_data,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +64,35 @@ impl CodecParser for DtsParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the DTS-HD extension syncword (0x64582025) in data.
|
||||||
|
/// Returns the byte offset of the sync, or None.
|
||||||
|
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
|
||||||
|
if data.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for i in 0..=data.len() - 4 {
|
||||||
|
if data[i] == DTS_HD_EXT_SYNC[0]
|
||||||
|
&& data[i + 1] == DTS_HD_EXT_SYNC[1]
|
||||||
|
&& data[i + 2] == DTS_HD_EXT_SYNC[2]
|
||||||
|
&& data[i + 3] == DTS_HD_EXT_SYNC[3]
|
||||||
|
{
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate DTS-HD extension frame size from the extension header.
|
||||||
|
/// The size field is at bytes 6-8 of the extension:
|
||||||
|
/// ((ext[6] & 0x1F) << 11) | (ext[7] << 3) | (ext[8] >> 5) + 1
|
||||||
|
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
|
||||||
|
debug_assert!(ext.len() >= 9);
|
||||||
|
let raw = ((ext[6] as usize & 0x1F) << 11)
|
||||||
|
| ((ext[7] as usize) << 3)
|
||||||
|
| ((ext[8] as usize) >> 5);
|
||||||
|
raw + 1
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -53,10 +107,137 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a DTS core frame with given payload size.
|
||||||
|
fn make_dts_core(payload_len: usize) -> Vec<u8> {
|
||||||
|
let mut data = vec![0x7F, 0xFE, 0x80, 0x01];
|
||||||
|
data.resize(4 + payload_len, 0xAA);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a DTS-HD extension header + payload.
|
||||||
|
/// ext_size is the value to encode (frame size = ext_size + 1 reported by dts_hd_ext_frame_size,
|
||||||
|
/// but we encode raw = ext_size so that dts_hd_ext_frame_size returns ext_size + 1).
|
||||||
|
fn make_dts_hd_ext(raw_size_field: usize, payload_fill: u8) -> Vec<u8> {
|
||||||
|
let total = raw_size_field + 1; // the size dts_hd_ext_frame_size will return
|
||||||
|
let byte6 = ((raw_size_field >> 11) & 0x1F) as u8;
|
||||||
|
let byte7 = ((raw_size_field >> 3) & 0xFF) as u8;
|
||||||
|
let byte8 = ((raw_size_field & 0x07) << 5) as u8;
|
||||||
|
let mut data = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, byte6, byte7, byte8];
|
||||||
|
while data.len() < total {
|
||||||
|
data.push(payload_fill);
|
||||||
|
}
|
||||||
|
data.truncate(total);
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DTS-HD extension sync detection ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_ext_sync_at_offset() {
|
||||||
|
let mut data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
|
||||||
|
data.extend_from_slice(&[0x64, 0x58, 0x20, 0x25]);
|
||||||
|
assert_eq!(find_dts_hd_ext_sync(&data), Some(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_ext_sync_none() {
|
||||||
|
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00];
|
||||||
|
assert_eq!(find_dts_hd_ext_sync(&data), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_ext_sync_at_start() {
|
||||||
|
let data = vec![0x64, 0x58, 0x20, 0x25, 0x00];
|
||||||
|
assert_eq!(find_dts_hd_ext_sync(&data), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_ext_sync_too_short() {
|
||||||
|
let data = vec![0x64, 0x58, 0x20];
|
||||||
|
assert_eq!(find_dts_hd_ext_sync(&data), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DTS-HD extension frame size ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ext_frame_size_basic() {
|
||||||
|
// raw_size_field = 100 → frame size = 101
|
||||||
|
let ext = make_dts_hd_ext(100, 0xBB);
|
||||||
|
assert_eq!(dts_hd_ext_frame_size(&ext), 101);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ext_frame_size_zero() {
|
||||||
|
// raw_size_field = 0 → frame size = 1
|
||||||
|
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
|
assert_eq!(dts_hd_ext_frame_size(&ext), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ext_frame_size_large() {
|
||||||
|
// raw = 0x1F << 11 | 0xFF << 3 | 0x07 = 0xFFFF = 65535
|
||||||
|
// frame_size = 65536
|
||||||
|
let ext = vec![0x64, 0x58, 0x20, 0x25, 0x00, 0x00, 0x1F, 0xFF, 0xFF];
|
||||||
|
// byte6=0x1F, byte7=0xFF, byte8=0xFF
|
||||||
|
// (0x1F << 11) | (0xFF << 3) | (0xFF >> 5) = 63488 | 2040 | 7 = 65535
|
||||||
|
assert_eq!(dts_hd_ext_frame_size(&ext), 65536);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse: core + extension frame ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_core_plus_extension() {
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let core = make_dts_core(20); // 24 bytes total
|
||||||
|
let ext = make_dts_hd_ext(50, 0xCC); // 51 bytes
|
||||||
|
let mut data = core.clone();
|
||||||
|
data.extend_from_slice(&ext);
|
||||||
|
|
||||||
|
let pes = make_pes(data.clone(), Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
// Frame should include core (24) + extension (51) = 75 bytes
|
||||||
|
assert_eq!(frames[0].data.len(), 24 + 51);
|
||||||
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||||
|
assert!(frames[0].keyframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_core_only() {
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let data = make_dts_core(10);
|
||||||
|
let pes = make_pes(data.clone(), Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].data, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_core_plus_extension_truncated_at_buffer_end() {
|
||||||
|
let mut parser = DtsParser::new();
|
||||||
|
let core = make_dts_core(4); // 8 bytes
|
||||||
|
// Extension claims 200 bytes but we only provide 20
|
||||||
|
let ext = make_dts_hd_ext(199, 0xDD); // wants 200 bytes
|
||||||
|
let mut data = core;
|
||||||
|
// Only append partial extension (first 20 bytes)
|
||||||
|
data.extend_from_slice(&ext[..20.min(ext.len())]);
|
||||||
|
|
||||||
|
let total_len = data.len();
|
||||||
|
let pes = make_pes(data, Some(0));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
// Should be clamped to actual data length
|
||||||
|
assert_eq!(frames[0].data.len(), total_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- basic tests (carried over) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_basic_frame() {
|
fn parse_basic_frame() {
|
||||||
let mut parser = DtsParser::new();
|
let mut parser = DtsParser::new();
|
||||||
// DTS core syncword: 7F FE 80 01 + payload
|
|
||||||
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
|
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
|
||||||
let pes = make_pes(data.clone(), Some(90000));
|
let pes = make_pes(data.clone(), Some(90000));
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! DVD bitmap subtitle (VobSub) parser.
|
||||||
|
//!
|
||||||
|
//! DVD subtitles are carried in PS private stream 1 with sub-stream IDs 0x20-0x3F.
|
||||||
|
//! Each subtitle display set may span multiple PES packets, but at the MKV level
|
||||||
|
//! we pass through the raw VobSub packets as-is — the container wraps them.
|
||||||
|
//!
|
||||||
|
//! For MKV: codec ID "S_VOBSUB".
|
||||||
|
//! All frames are keyframes (each is a complete bitmap).
|
||||||
|
|
||||||
|
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||||
|
|
||||||
|
pub struct DvdSubParser;
|
||||||
|
|
||||||
|
impl Default for DvdSubParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DvdSubParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodecParser for DvdSubParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
|
if pes.data.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||||
|
vec![Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: pes.data.clone(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mux::ts::PesPacket;
|
||||||
|
|
||||||
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
pid: 0x1200,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passthrough_data() {
|
||||||
|
let mut parser = DvdSubParser::new();
|
||||||
|
let sub_data = vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05];
|
||||||
|
let pes = make_pes(sub_data.clone(), Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].data, sub_data, "VobSub data should pass through unmodified");
|
||||||
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn always_keyframe() {
|
||||||
|
let mut parser = DvdSubParser::new();
|
||||||
|
for i in 0..3u8 {
|
||||||
|
let data = vec![0x00, i, 0x00, i + 1];
|
||||||
|
let pes = make_pes(data, Some(90000 * i as i64));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert!(frames[0].keyframe, "DVD subtitle frames should always be keyframes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_pes_returns_no_frames() {
|
||||||
|
let mut parser = DvdSubParser::new();
|
||||||
|
let pes = make_pes(Vec::new(), Some(0));
|
||||||
|
assert!(parser.parse(&pes).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_private_none() {
|
||||||
|
let parser = DvdSubParser::new();
|
||||||
|
assert!(parser.codec_private().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pts_defaults_to_zero() {
|
||||||
|
let mut parser = DvdSubParser::new();
|
||||||
|
let pes = make_pes(vec![0x01, 0x02], None);
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].pts_ns, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ const NAL_VPS: u8 = 32;
|
|||||||
const NAL_SPS: u8 = 33;
|
const NAL_SPS: u8 = 33;
|
||||||
const NAL_PPS: u8 = 34;
|
const NAL_PPS: u8 = 34;
|
||||||
const NAL_AUD: u8 = 35;
|
const NAL_AUD: u8 = 35;
|
||||||
|
// Dolby Vision RPU (Reference Processing Unit) — NAL type 62 (UNSPEC62).
|
||||||
|
// This is NOT filtered: all NAL types except VPS/SPS/PPS/AUD pass through
|
||||||
|
// to frame data, so DV enhancement layer RPU NALs are preserved automatically.
|
||||||
|
const _NAL_UNSPEC62_DV_RPU: u8 = 62;
|
||||||
// IRAP types (keyframes): BLA, IDR, CRA
|
// IRAP types (keyframes): BLA, IDR, CRA
|
||||||
const NAL_BLA_W_LP: u8 = 16;
|
const NAL_BLA_W_LP: u8 = 16;
|
||||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||||
@@ -467,4 +471,100 @@ mod tests {
|
|||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Dolby Vision enhancement layer ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dv_rpu_nal_preserved() {
|
||||||
|
// Dolby Vision enhancement layer streams contain RPU (Reference Processing
|
||||||
|
// Unit) metadata as NAL type 62 (UNSPEC62). The HEVC parser must pass these
|
||||||
|
// through to the frame data — only VPS/SPS/PPS/AUD are stripped.
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
|
||||||
|
// VPS (type 32) — should be stripped from frame data
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(32));
|
||||||
|
data.extend_from_slice(&[0xAA, 0xBB]);
|
||||||
|
|
||||||
|
// SPS (type 33) — should be stripped from frame data
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(33));
|
||||||
|
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
|
||||||
|
|
||||||
|
// PPS (type 34) — should be stripped from frame data
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(34));
|
||||||
|
data.extend_from_slice(&[0xDD, 0xEE]);
|
||||||
|
|
||||||
|
// IDR_W_RADL slice (type 19) — should appear in frame data
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
let idr_hdr = hevc_nal_header(19);
|
||||||
|
data.extend_from_slice(&idr_hdr);
|
||||||
|
data.extend_from_slice(&[0x10, 0x20, 0x30]);
|
||||||
|
|
||||||
|
// Dolby Vision RPU (type 62 = UNSPEC62) — MUST appear in frame data
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
let rpu_hdr = hevc_nal_header(62);
|
||||||
|
data.extend_from_slice(&rpu_hdr);
|
||||||
|
let rpu_payload = [0xF0, 0xF1, 0xF2, 0xF3, 0xF4];
|
||||||
|
data.extend_from_slice(&rpu_payload);
|
||||||
|
|
||||||
|
let pes = make_pes(data, Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1, "should produce one frame");
|
||||||
|
assert!(frames[0].keyframe, "IDR should mark keyframe");
|
||||||
|
|
||||||
|
// Verify the frame data contains both the IDR NAL and the RPU NAL.
|
||||||
|
// Frame data is length-prefixed NALUs (4-byte big-endian length + NAL bytes).
|
||||||
|
let fd = &frames[0].data;
|
||||||
|
|
||||||
|
// Walk the length-prefixed NALUs and collect their types
|
||||||
|
let mut nal_types = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset + 4 <= fd.len() {
|
||||||
|
let length = u32::from_be_bytes([fd[offset], fd[offset + 1], fd[offset + 2], fd[offset + 3]]) as usize;
|
||||||
|
offset += 4;
|
||||||
|
assert!(offset + length <= fd.len(), "NAL length exceeds frame data");
|
||||||
|
let nal_type = (fd[offset] >> 1) & 0x3F;
|
||||||
|
nal_types.push(nal_type);
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
nal_types.contains(&19),
|
||||||
|
"frame data must contain IDR NAL (type 19), got: {:?}",
|
||||||
|
nal_types
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
nal_types.contains(&62),
|
||||||
|
"frame data must contain Dolby Vision RPU NAL (type 62), got: {:?}",
|
||||||
|
nal_types
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
nal_types.len(),
|
||||||
|
2,
|
||||||
|
"frame data should have exactly 2 NALs (IDR + RPU), got: {:?}",
|
||||||
|
nal_types
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify RPU payload is intact
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset + 4 <= fd.len() {
|
||||||
|
let length = u32::from_be_bytes([fd[offset], fd[offset + 1], fd[offset + 2], fd[offset + 3]]) as usize;
|
||||||
|
offset += 4;
|
||||||
|
let nal_type = (fd[offset] >> 1) & 0x3F;
|
||||||
|
if nal_type == 62 {
|
||||||
|
// NAL = 2-byte header + payload
|
||||||
|
let nal_payload = &fd[offset + 2..offset + length];
|
||||||
|
assert_eq!(
|
||||||
|
nal_payload, &rpu_payload,
|
||||||
|
"RPU payload must be preserved verbatim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! BD/DVD LPCM (Linear PCM) audio parser.
|
||||||
|
//!
|
||||||
|
//! BD LPCM PES packets have a 4-byte header:
|
||||||
|
//! Bytes 0-1: audio frame number
|
||||||
|
//! Byte 2: reserved
|
||||||
|
//! Byte 3: quantization (bits 7-6), sample rate (bits 5-4), channel assignment (bits 3-0)
|
||||||
|
//!
|
||||||
|
//! DVD LPCM (private stream 1, sub-stream 0xA0-0xA7) has a 3-byte header.
|
||||||
|
//!
|
||||||
|
//! The raw PCM data follows the header. No framing is needed — each PES
|
||||||
|
//! payload minus its header is one complete audio frame.
|
||||||
|
//!
|
||||||
|
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
|
||||||
|
//! All frames are keyframes (uncompressed audio).
|
||||||
|
|
||||||
|
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||||
|
|
||||||
|
/// BD LPCM header size in bytes.
|
||||||
|
const BD_LPCM_HEADER_SIZE: usize = 4;
|
||||||
|
|
||||||
|
pub struct LpcmParser;
|
||||||
|
|
||||||
|
impl Default for LpcmParser {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LpcmParser {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodecParser for LpcmParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||||
|
// Skip the BD LPCM header (4 bytes).
|
||||||
|
// If the PES is too short to contain header + data, return nothing.
|
||||||
|
if pes.data.len() <= BD_LPCM_HEADER_SIZE {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||||
|
vec![Frame {
|
||||||
|
pts_ns,
|
||||||
|
keyframe: true,
|
||||||
|
data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mux::ts::PesPacket;
|
||||||
|
|
||||||
|
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
pid: 0x1100,
|
||||||
|
pts,
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_skip_extracts_pcm_data() {
|
||||||
|
let mut parser = LpcmParser::new();
|
||||||
|
// 4-byte LPCM header + 6 bytes of PCM data
|
||||||
|
let header = vec![0x00, 0x01, 0x00, 0b10_01_0001]; // frame#=1, quant=24bit, rate=48k, ch=1
|
||||||
|
let pcm_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE];
|
||||||
|
let mut pes_data = header;
|
||||||
|
pes_data.extend_from_slice(&pcm_data);
|
||||||
|
|
||||||
|
let pes = make_pes(pes_data, Some(90000));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0].data, pcm_data);
|
||||||
|
assert_eq!(frames[0].pts_ns, 1_000_000_000); // 90000 ticks = 1 second
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn always_keyframe() {
|
||||||
|
let mut parser = LpcmParser::new();
|
||||||
|
for i in 0..5u8 {
|
||||||
|
let data = vec![0x00, 0x00, 0x00, 0x00, i, i + 1];
|
||||||
|
let pes = make_pes(data, Some(90000 * i as i64));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert!(frames[0].keyframe, "LPCM frames should always be keyframes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_pes_returns_no_frames() {
|
||||||
|
let mut parser = LpcmParser::new();
|
||||||
|
let pes = make_pes(Vec::new(), Some(0));
|
||||||
|
assert!(parser.parse(&pes).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn header_only_pes_returns_no_frames() {
|
||||||
|
let mut parser = LpcmParser::new();
|
||||||
|
// Exactly 4 bytes = header only, no PCM data
|
||||||
|
let pes = make_pes(vec![0x00, 0x01, 0x00, 0x00], Some(0));
|
||||||
|
assert!(parser.parse(&pes).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_private_none() {
|
||||||
|
let parser = LpcmParser::new();
|
||||||
|
assert!(parser.codec_private().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pts_conversion() {
|
||||||
|
let mut parser = LpcmParser::new();
|
||||||
|
// PTS = 0 should give pts_ns = 0
|
||||||
|
let pes = make_pes(vec![0; 8], Some(0));
|
||||||
|
let frames = parser.parse(&pes);
|
||||||
|
assert_eq!(frames[0].pts_ns, 0);
|
||||||
|
|
||||||
|
// No PTS should default to 0
|
||||||
|
let pes_no_pts = make_pes(vec![0; 8], None);
|
||||||
|
let frames = parser.parse(&pes_no_pts);
|
||||||
|
assert_eq!(frames[0].pts_ns, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
|
|
||||||
pub mod ac3;
|
pub mod ac3;
|
||||||
pub mod dts;
|
pub mod dts;
|
||||||
|
pub mod dvdsub;
|
||||||
pub mod h264;
|
pub mod h264;
|
||||||
pub mod hevc;
|
pub mod hevc;
|
||||||
|
pub mod lpcm;
|
||||||
pub mod mpeg2;
|
pub mod mpeg2;
|
||||||
pub mod pgs;
|
pub mod pgs;
|
||||||
pub mod truehd;
|
pub mod truehd;
|
||||||
@@ -86,7 +88,8 @@ pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
|
|||||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||||
Codec::Lpcm => Box::new(PassthroughParser::new(true)),
|
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
|
||||||
|
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new()),
|
||||||
_ => Box::new(PassthroughParser::new(true)),
|
_ => Box::new(PassthroughParser::new(true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ fn codec_to_str(c: Codec) -> String {
|
|||||||
Codec::Ac3Plus => "eac3",
|
Codec::Ac3Plus => "eac3",
|
||||||
Codec::Lpcm => "lpcm",
|
Codec::Lpcm => "lpcm",
|
||||||
Codec::Pgs => "pgs",
|
Codec::Pgs => "pgs",
|
||||||
|
Codec::DvdSub => "dvdsub",
|
||||||
Codec::Unknown(_) => "unknown",
|
Codec::Unknown(_) => "unknown",
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
|
|||||||
+5
-1
@@ -80,9 +80,13 @@ impl MkvTrack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||||
|
let codec_id = match s.codec {
|
||||||
|
Codec::DvdSub => "S_VOBSUB",
|
||||||
|
_ => "S_HDMV/PGS",
|
||||||
|
};
|
||||||
Self {
|
Self {
|
||||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||||
codec_id: "S_HDMV/PGS",
|
codec_id,
|
||||||
language: s.language.clone(),
|
language: s.language.clone(),
|
||||||
name: String::new(),
|
name: String::new(),
|
||||||
codec_private: None,
|
codec_private: None,
|
||||||
|
|||||||
@@ -492,6 +492,7 @@ fn parse_track(r: &mut (impl Read + Seek), size: u64) -> io::Result<Option<crate
|
|||||||
"A_DTS" => Codec::Dts,
|
"A_DTS" => Codec::Dts,
|
||||||
"A_PCM/INT/BIG" => Codec::Lpcm,
|
"A_PCM/INT/BIG" => Codec::Lpcm,
|
||||||
"S_HDMV/PGS" => Codec::Pgs,
|
"S_HDMV/PGS" => Codec::Pgs,
|
||||||
|
"S_VOBSUB" => Codec::DvdSub,
|
||||||
_ => Codec::Unknown(0),
|
_ => Codec::Unknown(0),
|
||||||
};
|
};
|
||||||
let res = format!("{}p", ph);
|
let res = format!("{}p", ph);
|
||||||
|
|||||||
Reference in New Issue
Block a user