diff --git a/src/disc.rs b/src/disc.rs index 84f2b1d..27c86ad 100644 --- a/src/disc.rs +++ b/src/disc.rs @@ -415,14 +415,39 @@ impl Disc { /// println!("{} — {} streams", title.duration_display(), title.streams.len()); /// } /// ``` + /// Scan a disc. One pipeline, one order: + /// 1. Read capacity + /// 2. Read UDF filesystem + /// 3. Resolve AACS keys (all via UDF, no SCSI commands) + /// 4. Parse playlists + streams + /// 5. Apply labels + /// + /// The session must be open and unlocked (DriveSession::open handles this). + /// All disc reads use standard READ(10) via UDF — no vendor SCSI commands. pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result { - // Step 1: Read capacity + use crate::aacs::{self, KeyDb}; + + // 1. Capacity let capacity = Self::read_capacity(session)?; - // Step 2: Parse UDF filesystem + // 2. UDF filesystem let udf_fs = udf::read_filesystem(session)?; - // Step 3: Find and parse MPLS playlists + // 3. AACS — read files from disc via UDF, resolve keys via KEYDB + let encrypted = udf_fs.find_dir("/AACS").is_some() + || udf_fs.find_dir("/BDMV/AACS").is_some(); + + let aacs = if encrypted { + if let Some(keydb_path) = opts.resolve_keydb() { + Self::resolve_aacs(&udf_fs, session, &keydb_path).ok() + } else { + None + } + } else { + None + }; + + // 4. Playlists let mut titles = Vec::new(); if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") { for entry in &playlist_dir.entries { @@ -436,53 +461,20 @@ impl Disc { } } } - - // Sort: longest first titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal)); - // Step 4: Read disc title from META/DL/bdmt_eng.xml + // 5. Metadata + labels let meta_title = Self::read_meta_title(session, &udf_fs); - - // Step 5: Enhance streams with disc config file labels (if available) crate::labels::apply(session, &udf_fs, &mut titles); - // Step 6: Detect AACS encryption - let encrypted = udf_fs.find_dir("/AACS").is_some() - || udf_fs.find_dir("/BDMV/AACS").is_some(); - - // Step 7: If encrypted and KEYDB available, authenticate and derive keys - let aacs = if encrypted { - if let Some(keydb_path) = opts.resolve_keydb() { - match Self::setup_aacs(session, &keydb_path) { - Ok(state) => Some(state), - Err(_) => None, // keys not found, continue without decryption - } - } else { - None - } - } else { - None - }; - - // Derive disc format from main title video codec + // 6. Derive format, layers, region let format = Self::detect_format(&titles); - - // Derive layer count from capacity - // BD-25 single layer: up to ~12M sectors (~25GB) - // BD-50 dual layer: ~12M-25M sectors (~50GB) - // BD-66/100 UHD: 25M+ sectors let layers = if capacity > 24_000_000 { 2 } else { 1 }; - - // UHD is always region-free. BD/DVD region parsing TODO. - let region = if format == DiscFormat::Uhd { - DiscRegion::Free - } else { - DiscRegion::Free // TODO: parse from index.bdmv - }; + let region = if format == DiscFormat::Uhd { DiscRegion::Free } else { DiscRegion::Free }; Ok(Disc { volume_id: udf_fs.volume_id.clone(), - meta_title: meta_title, + meta_title, format, capacity_sectors: capacity, capacity_bytes: capacity as u64 * 2048, @@ -494,98 +486,63 @@ impl Disc { }) } - /// Set up AACS decryption for this disc. - /// Call after scan() to enable transparent content decryption. - pub fn setup_aacs( + /// Resolve AACS keys from disc files + KEYDB. No SCSI commands. + /// Reads Unit_Key_RO.inf, Content Certificate, and MKB from UDF. + fn resolve_aacs( + udf_fs: &udf::UdfFs, session: &mut DriveSession, keydb_path: &std::path::Path, ) -> Result { use crate::aacs::{self, KeyDb}; - use crate::aacs::handshake; - // Load KEYDB let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError { detail: format!("failed to load KEYDB: {}", e), })?; - // Step 1: Try SCSI handshake for Volume ID + read_data_key - // Open a separate transport (AACS auth must happen before raw mode). - // If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives), - // fall back to disc-hash-only KEYDB lookup. - let device_path = session.device_path().to_string(); - let mut vid: Option<[u8; 16]> = None; - let mut read_data_key: Option<[u8; 16]> = None; - - if !device_path.is_empty() { - if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) { - if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) { - if let Ok(mut auth) = handshake::aacs2_authenticate( - &mut aacs_session, - &hc.private_key, - &hc.certificate, - hc.private_key_v2.as_ref(), - hc.certificate_v2.as_deref(), - ) { - vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok(); - read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth) - .ok().map(|(rdk, _)| rdk); - } - } - } - // Handshake failure is not fatal — we can still resolve via disc hash - } - - // Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session) - let udf_fs = udf::read_filesystem(session)?; + // Read AACS files from disc via UDF (standard READ(10), no vendor commands) let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf") .or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf")) .map_err(|_| Error::AacsError { - detail: "failed to read Unit_Key_RO.inf from disc".into(), + detail: "Unit_Key_RO.inf not found on disc".into(), })?; - // Step 3: Read Content Certificate (optional — for AACS version detection) let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer") .or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer")) .ok(); - // Step 4: Resolve keys - // If we have VID from handshake, use full 4-path chain. - // If no VID (handshake failed), use disc-hash-only KEYDB lookup. - let mkb_data = aacs::read_mkb_from_drive(session).ok(); + let mkb_data = udf_fs.read_file(session, "/AACS/MKB_RW.inf") + .or_else(|_| udf_fs.read_file(session, "/AACS/MKB_RO.inf")) + .ok(); let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version); - // Use a zero VID placeholder if handshake failed — resolve_keys - // will still work via disc hash (path 1) - let vid_for_resolve = vid.unwrap_or([0u8; 16]); - + // Resolve: disc hash → KEYDB lookup → VUK → unit keys + let vid_zero = [0u8; 16]; let resolved = aacs::resolve_keys( &uk_ro_data, cc_data.as_deref(), - &vid_for_resolve, + &vid_zero, &keydb, mkb_data.as_deref(), ).ok_or_else(|| Error::AacsError { - detail: "failed to resolve AACS keys — disc not in KEYDB".into(), + detail: "disc not in KEYDB".into(), })?; - let key_source = match resolved.key_source { - 1 => KeySource::KeyDb, - 2 => KeySource::KeyDbDerived, - 3 => KeySource::ProcessingKey, - 4 => KeySource::DeviceKey, - _ => KeySource::KeyDb, - }; - Ok(AacsState { version: if resolved.aacs2 { 2 } else { 1 }, bus_encryption: resolved.bus_encryption, mkb_version: mkb_ver, disc_hash: aacs::disc_hash_hex(&resolved.disc_hash), - key_source, + key_source: match resolved.key_source { + 1 => KeySource::KeyDb, + 2 => KeySource::KeyDbDerived, + 3 => KeySource::ProcessingKey, + 4 => KeySource::DeviceKey, + _ => KeySource::KeyDb, + }, vuk: resolved.vuk, unit_keys: resolved.unit_keys, - read_data_key, - volume_id: vid.unwrap_or([0u8; 16]), + read_data_key: None, + volume_id: [0u8; 16], }) } @@ -689,16 +646,18 @@ impl Disc { pkt_count = clip_info.source_packet_count; total_size += pkt_count as u64 * 192; - // Get the m2ts file's absolute starting LBA on disc + // Get m2ts file start LBA and compute extent from packet count. + // BD-ROM m2ts files are contiguous on disc (mastering requirement). let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id); let file_lba = udf_fs.file_start_lba(session, &m2ts_path).unwrap_or(0); - - let mut clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time); - // Extents from CLPI are relative to m2ts file start — add file LBA - for ext in &mut clip_extents { - ext.start_lba += file_lba; + let total_bytes = pkt_count as u64 * 192; + let total_sectors = ((total_bytes + 2047) / 2048) as u32; + if total_sectors > 0 && file_lba > 0 { + extents.push(Extent { + start_lba: file_lba, + sector_count: total_sectors, + }); } - extents.extend(clip_extents); } } @@ -782,6 +741,13 @@ impl Disc { // ─── Decrypted reader ────────────────────────────────────────────────────── /// A reader that reads m2ts content, decrypting transparently if needed. +/// +/// Adaptive read strategy: +/// - Starts at max batch size (510 sectors ≈ 1MB) and full disc speed +/// - On read error: halves batch size, brief pause for drive recovery +/// - On repeated errors: reduces disc spin speed (scratched region) +/// - On success streak: ramps batch back up, then restores disc speed +/// - At minimum batch + still failing: retries once, then skips + zero-fills pub struct ContentReader<'a> { session: &'a mut DriveSession, aacs: Option<&'a AacsState>, @@ -792,10 +758,16 @@ pub struct ContentReader<'a> { read_buf: Vec, buf_pos: usize, buf_len: usize, - /// Current batch size (adapts on errors) + /// Current batch size in sectors (adapts on errors) batch_sectors: u16, - /// Consecutive successful batch reads (for ramp-up) + /// Consecutive successful batch reads ok_streak: u32, + /// Consecutive errors at current position + error_streak: u32, + /// Current speed tier index (0 = max, higher = slower) + speed_tier: usize, + /// Last time maintain_speed was called + last_speed_maintain: std::time::Instant, /// Total read errors encountered pub errors: u32, } @@ -821,7 +793,12 @@ impl Disc { detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()), })?; - // Set drive to max read speed + // Ensure drive is unlocked + if !session.is_unlocked() { + session.unlock()?; + } + + // Set max read speed — nothing else. No calibration, no probes. let speed_cdb = crate::scsi::build_set_cd_speed(0xFFFF); let mut dummy = [0u8; 0]; let _ = session.scsi_execute(&speed_cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000); @@ -838,17 +815,61 @@ impl Disc { buf_len: 0, batch_sectors: MAX_BATCH_SECTORS, ok_streak: 0, + error_streak: 0, + speed_tier: 0, + last_speed_maintain: std::time::Instant::now(), errors: 0, }) } } +/// Detect the maximum transfer size in sectors for a device. +/// Reads /sys/block//queue/max_hw_sectors_kb on Linux. +/// Returns a value aligned to 3 sectors (one aligned unit). +fn detect_max_batch_sectors(device_path: &str) -> u16 { + // Extract block device name: /dev/sr0 → sr0 + let dev_name = device_path.rsplit('/').next().unwrap_or(""); + if !dev_name.is_empty() { + let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", dev_name); + if let Ok(content) = std::fs::read_to_string(&sysfs_path) { + if let Ok(kb) = content.trim().parse::() { + // Convert KB to sectors (1 sector = 2 KB on disc = 2048 bytes) + let sectors = (kb / 2) as u16; + // Align down to 3 (one aligned unit) and cap at a reasonable max + let aligned = (sectors / 3) * 3; + if aligned >= MIN_BATCH_SECTORS { + return aligned.min(MAX_BATCH_SECTORS); + } + } + } + } + // Fallback: conservative default + MAX_BATCH_SECTORS +} + /// Read strategy constants -const MAX_BATCH_SECTORS: u16 = 96; // 32 aligned units = 192KB per command (fast) -const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (slow, for error recovery) -const RAMP_UP_AFTER: u32 = 10; // successful reads before ramping back up +const MAX_BATCH_SECTORS: u16 = 510; // 170 aligned units ≈ 1MB (kernel caps to hw limit) +const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery) +const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size +const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed +const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed + +/// Disc speed tiers (KB/s for SET CD SPEED). +/// Blu-ray: 1x=4500, 2x=9000, 4x=18000, 8x=36000, 12x=54000 +const SPEED_TIERS: &[u16] = &[ + 0xFFFF, // tier 0: max (drive decides, typically 8-12x) + 36000, // tier 1: 8x BD (~36 MB/s) + 18000, // tier 2: 4x BD (~18 MB/s) + 9000, // tier 3: 2x BD (~9 MB/s) + 4500, // tier 4: 1x BD (~4.5 MB/s) — last resort +]; impl<'a> ContentReader<'a> { + /// Total bytes across all extents (for progress display). + pub fn total_bytes(&self) -> u64 { + self.extents.iter().map(|e| e.sector_count as u64 * 2048).sum() + } + /// Read the next aligned unit (6144 bytes). /// Automatically decrypted if AACS keys are available. /// Returns None when all extents are exhausted. @@ -866,36 +887,99 @@ impl<'a> ContentReader<'a> { let mut unit = self.read_buf[start..end].to_vec(); // Decrypt if needed + self.decrypt_unit(&mut unit); + self.buf_pos += 1; + Ok(Some(unit)) + } + + /// Read the next batch of aligned units, decrypted in-place. + /// Returns the decrypted data as a single contiguous slice. + /// More efficient than read_unit() — one write_all() per batch instead of per unit. + /// Returns None when all extents are exhausted. + pub fn read_batch(&mut self) -> Result> { + if !self.fill_buffer()? { + return Ok(None); + } + + // Decrypt all units in the buffer in-place + let unit_len = crate::aacs::ALIGNED_UNIT_LEN; if let Some(aacs) = &self.aacs { - if crate::aacs::is_unit_encrypted(&unit) { + let uk = aacs.unit_keys.get(self.unit_key_idx) + .map(|(_, k)| *k) + .unwrap_or([0u8; 16]); + let rdk = aacs.read_data_key.as_ref(); + + for i in 0..self.buf_len { + let start = i * unit_len; + let end = start + unit_len; + let unit = &mut self.read_buf[start..end]; + if crate::aacs::is_unit_encrypted(unit) { + crate::aacs::decrypt_unit_full(unit, &uk, rdk); + } + } + } + + let total_bytes = self.buf_len * unit_len; + self.buf_pos = self.buf_len; // mark fully consumed + Ok(Some(&self.read_buf[..total_bytes])) + } + + /// Decrypt a single aligned unit in-place if needed. + fn decrypt_unit(&self, unit: &mut [u8]) { + if let Some(aacs) = &self.aacs { + if crate::aacs::is_unit_encrypted(unit) { let uk = aacs.unit_keys.get(self.unit_key_idx) .map(|(_, k)| *k) .unwrap_or([0u8; 16]); crate::aacs::decrypt_unit_full( - &mut unit, + unit, &uk, aacs.read_data_key.as_ref(), ); } } + } - self.buf_pos += 1; - Ok(Some(unit)) + /// Read sectors via standard READ(10) 0x00. + /// calibration primers. Standard reads are faster on most drives. + fn read_sectors(&mut self, lba: u32, count: u16) -> Result<()> { + self.session.read_content(lba, count, &mut self.read_buf)?; + Ok(()) + } + + /// Set disc spin speed via SCSI SET CD SPEED. + fn set_speed(&mut self, tier: usize) { + let tier = tier.min(SPEED_TIERS.len() - 1); + if tier != self.speed_tier { + self.speed_tier = tier; + let speed_kbs = SPEED_TIERS[tier]; + let cdb = crate::scsi::build_set_cd_speed(speed_kbs); + let mut dummy = [0u8; 0]; + let _ = self.session.scsi_execute( + &cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000, + ); + } } /// Read a batch of sectors into the internal buffer. - /// Adapts batch size on errors: shrinks on failure, grows on success. + /// + /// Adaptive strategy: + /// 1. Read at current batch size + /// 2. On success: ramp batch up (double after 5 successes), + /// then restore disc speed (after 50 at max batch) + /// 3. On error: halve batch, pause. After 3 consecutive errors, + /// also reduce disc spin speed (scratched/damaged region). + /// 4. At min batch + still failing: retry once, then skip + zero-fill. fn fill_buffer(&mut self) -> Result { loop { if self.current_extent >= self.extents.len() { - eprintln!(" [done] extent {}/{} offset {} errors {}", - self.current_extent, self.extents.len(), self.current_offset, self.errors); return Ok(false); } - let extent = &self.extents[self.current_extent]; - let remaining = extent.sector_count - self.current_offset; + let ext_start = self.extents[self.current_extent].start_lba; + let ext_sectors = self.extents[self.current_extent].sector_count; + let remaining = ext_sectors - self.current_offset; // Align to 3 sectors (one aligned unit) let sectors_to_read = remaining.min(self.batch_sectors as u32) as u16; @@ -906,30 +990,32 @@ impl<'a> ContentReader<'a> { continue; } - let lba = extent.start_lba + self.current_offset; + let lba = ext_start + self.current_offset; let byte_count = sectors_to_read as usize * 2048; self.read_buf.resize(byte_count, 0); - if self.current_offset < 100 || sectors_to_read < self.batch_sectors { - eprintln!(" [fill] lba={} count={} offset={}/{} batch={} err={} remaining={}", - lba, sectors_to_read, self.current_offset, extent.sector_count, - self.batch_sectors, self.errors, remaining); - } - match self.session.read_content(lba, sectors_to_read, &mut self.read_buf) { + match self.read_sectors(lba, sectors_to_read) { Ok(_) => { self.buf_len = sectors_to_read as usize / 3; self.buf_pos = 0; self.current_offset += sectors_to_read as u32; + self.error_streak = 0; - if self.current_offset >= extent.sector_count { + if self.current_offset >= ext_sectors { self.current_extent += 1; self.current_offset = 0; } - // Ramp up batch size after consecutive successes + // Ramp up: batch size first, then disc speed self.ok_streak += 1; - if self.ok_streak >= RAMP_UP_AFTER && self.batch_sectors < MAX_BATCH_SECTORS { - self.batch_sectors = (self.batch_sectors * 2).min(MAX_BATCH_SECTORS); + if self.batch_sectors < MAX_BATCH_SECTORS { + if self.ok_streak >= RAMP_BATCH_AFTER { + self.batch_sectors = (self.batch_sectors * 2).min(MAX_BATCH_SECTORS); + self.ok_streak = 0; + } + } else if self.speed_tier > 0 && self.ok_streak >= RAMP_SPEED_AFTER { + // At max batch for a while — try faster disc speed + self.set_speed(self.speed_tier - 1); self.ok_streak = 0; } @@ -937,30 +1023,39 @@ impl<'a> ContentReader<'a> { } Err(_) => { self.errors += 1; + self.error_streak += 1; self.ok_streak = 0; + // Reduce disc speed after repeated errors (physical problem) + if self.error_streak >= SLOW_SPEED_AFTER + && self.speed_tier < SPEED_TIERS.len() - 1 + { + self.set_speed(self.speed_tier + 1); + self.error_streak = 0; // reset — give new speed a chance + } + if self.batch_sectors > MIN_BATCH_SECTORS { // Shrink batch and retry self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS); - // Brief pause to let drive recover std::thread::sleep(std::time::Duration::from_millis(100)); } else { - // At minimum batch — retry once with a longer pause + // At minimum batch — retry once with longer pause std::thread::sleep(std::time::Duration::from_millis(500)); self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0); - if self.session.read_content(lba, MIN_BATCH_SECTORS, &mut self.read_buf).is_ok() { + if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() { self.buf_len = 1; self.buf_pos = 0; + self.error_streak = 0; self.current_offset += MIN_BATCH_SECTORS as u32; - if self.current_offset >= extent.sector_count { + if self.current_offset >= ext_sectors { self.current_extent += 1; self.current_offset = 0; } return Ok(true); } - // Still failing — skip this unit + // Still failing — skip this unit (zero-fill) self.current_offset += 3; - if self.current_offset >= extent.sector_count { + if self.current_offset >= ext_sectors { self.current_extent += 1; self.current_offset = 0; } @@ -976,16 +1071,6 @@ impl<'a> ContentReader<'a> { } } -fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> { - let cdb = [ - crate::scsi::SCSI_READ_10, 0x00, - (lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8, - 0x00, 0x00, 0x01, 0x00, - ]; - session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?; - Ok(()) -} - // ─── Format helpers ──────────────────────────────────────────────────────── fn format_resolution(video_format: u8, _video_rate: u8) -> String { diff --git a/src/drive.rs b/src/drive.rs index 4b217a7..898cbec 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -30,23 +30,32 @@ pub struct DriveSession { } impl DriveSession { - /// Open a drive, identify it, match a profile, and unlock for raw reads. + /// Open a drive — identify, wait for disc, and unlock for raw reads. /// - /// This is the standard entry point. After `open()`, the drive is ready - /// for sector reads, disc scanning, and content extraction. + /// This is the standard entry point. After `open()`, the drive is + /// ready for scanning and content reads. pub fn open(device: &Path) -> Result { let mut session = Self::open_no_unlock(device)?; session.wait_ready()?; - let _ = session.unlock(); // silently ignore — unencrypted discs don't need it + let _ = session.unlock(); Ok(session) } - /// Open a drive WITHOUT unlocking. + /// Open a drive and immediately unlock for raw reads. /// - /// Used when AACS authentication must happen before raw mode. - /// The AACS SCSI handshake requires the drive's standard firmware - /// state — unlocking puts the drive in vendor-specific raw mode - /// which disables the AACS layer. + /// Use this when you need raw disc access without AACS (e.g. capture, + /// sector dumps). Skips AACS authentication — cannot be done after unlock. + pub fn open_unlocked(device: &Path) -> Result { + let mut session = Self::open_no_unlock(device)?; + session.wait_ready()?; + let _ = session.unlock(); + Ok(session) + } + + /// Open a drive — identify only, no wait, no unlock. + /// + /// Low-level entry point. Caller is responsible for wait_ready() + /// and unlock() ordering. pub fn open_no_unlock(device: &Path) -> Result { let mut transport = crate::scsi::open(device)?; let profiles = profile::load_bundled()?; @@ -139,6 +148,12 @@ impl DriveSession { self.platform.calibrate(self.scsi.as_mut()) } + /// Maintain read speed during bulk reading. + /// Call every ~2 seconds during ripping to prevent speed decay. + pub fn maintain_speed(&mut self, lba: u32) -> Result<()> { + self.platform.maintain_speed(self.scsi.as_mut(), lba) + } + /// Read raw disc sectors via platform-specific command. pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf) @@ -205,6 +220,94 @@ impl DriveSession { } } +/// Discover optical drives on the system. +/// +/// Scans `/dev/sg0` through `/dev/sg15` (Linux SCSI Generic devices), +/// sends INQUIRY to each, and returns paths for optical drives (device type 5). +/// Always uses sg devices — sr devices have kernel-level speed management +/// that interferes with raw disc access. +/// +/// Returns a list of (device_path, DriveId) for each found drive. +pub fn find_drives() -> Vec<(String, DriveId)> { + let mut drives = Vec::new(); + for i in 0..16 { + let path = format!("/dev/sg{}", i); + if !std::path::Path::new(&path).exists() { + continue; + } + if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) { + if let Ok(id) = DriveId::from_drive(transport.as_mut()) { + // INQUIRY device type 5 = CD/DVD/BD + // We check by trying to match a profile — only optical drives have profiles + let profiles = match profile::load_bundled() { + Ok(p) => p, + Err(_) => continue, + }; + if profile::find_by_drive_id(&profiles, &id).is_some() { + drives.push((path, id)); + } + } + } + } + drives +} + +/// Find the first optical drive on the system. +/// Returns the sg device path, or None if no drive found. +pub fn find_drive() -> Option { + find_drives().into_iter().next().map(|(path, _)| path) +} + +/// Resolve a device path to the correct sg device. +/// +/// If the user passes `/dev/sr0`, maps it to the corresponding `/dev/sg*`. +/// If they pass `/dev/sg*`, validates it exists. +/// Returns `(resolved_path, warning)` where warning is set if the path was remapped. +pub fn resolve_device(path: &str) -> Result<(String, Option)> { + // Already an sg device — use as-is + if path.contains("/sg") { + if !std::path::Path::new(path).exists() { + return Err(Error::DeviceNotFound { path: path.to_string() }); + } + return Ok((path.to_string(), None)); + } + + // sr device — find the matching sg device by comparing INQUIRY data + if path.contains("/sr") { + // Open the sr device to get its identity + let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?; + let sr_id = DriveId::from_drive(sr_transport.as_mut())?; + drop(sr_transport); + + // Find matching sg device + for (sg_path, sg_id) in find_drives() { + if sg_id.vendor_id == sr_id.vendor_id + && sg_id.product_id == sr_id.product_id + && sg_id.serial_number == sr_id.serial_number + { + let warning = format!( + "{} is a block device (sr) — using {} (sg) for raw access", + path, sg_path + ); + return Ok((sg_path, Some(warning))); + } + } + + // No sg match found — fall back to sr with warning + let warning = format!( + "{} is a block device (sr) — no matching sg device found, performance may be limited", + path + ); + return Ok((path.to_string(), Some(warning))); + } + + // Unknown device type — use as-is + if !std::path::Path::new(path).exists() { + return Err(Error::DeviceNotFound { path: path.to_string() }); + } + Ok((path.to_string(), None)) +} + /// Create the platform-specific driver for a given chipset. fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result> { match profile.chipset { diff --git a/src/lib.rs b/src/lib.rs index 25b2b53..d97a1d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,11 @@ //! # Quick Start //! //! ```no_run -//! use libfreemkv::{DriveSession, Disc, ScanOptions}; +//! use libfreemkv::{DriveSession, Disc, ScanOptions, find_drive}; //! use std::path::Path; //! -//! let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap(); +//! let device = find_drive().expect("no optical drive found"); +//! let mut session = DriveSession::open(Path::new(&device)).unwrap(); //! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap(); //! //! for title in &disc.titles { @@ -82,7 +83,7 @@ pub mod labels; pub mod keydb; pub use error::{Error, Result}; -pub use drive::DriveSession; +pub use drive::{DriveSession, find_drive, find_drives, resolve_device}; pub use identity::DriveId; pub use profile::{DriveProfile, Chipset}; pub use platform::{Platform, DriveStatus}; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 3040a69..950616c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -55,6 +55,14 @@ pub trait Platform { fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>; + /// Continuous speed management during reading. + /// + /// Called periodically (~every 2 seconds) during bulk reads. + /// Probes the current disc zone, reads drive registers, and + /// sends SET CD SPEED to maintain optimal read performance. + /// Without this, MediaTek drives drift back to 1x speed. + fn maintain_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()>; + /// Check if raw disc access mode is currently enabled. fn is_unlocked(&self) -> bool; } diff --git a/src/platform/mt1959.rs b/src/platform/mt1959.rs index ff79ecd..b876f62 100644 --- a/src/platform/mt1959.rs +++ b/src/platform/mt1959.rs @@ -13,13 +13,20 @@ use crate::profile::DriveProfile; use crate::scsi::{self, DataDirection, ScsiTransport}; use super::{Platform, DriveStatus}; +/// BD 1x speed in KB/s — used to convert speed multipliers to SET CD SPEED values. +const BD_1X_SPEED: u16 = 4500; + /// MT1959 driver state. pub struct Mt1959 { profile: DriveProfile, mode: u8, buffer_id: u8, unlocked: bool, - speed_table: [u16; 64], + /// Speed table: maps disc zone (probe address >> 8) to speed in KB/s. + /// Populated by calibrate(). Entry 0 = address 0x0000, entry 255 = address 0xFF00. + speed_table: [u16; 256], + /// Total disc sectors — for mapping LBA to zone index in speed table. + disc_sectors: u32, calibrated: bool, } @@ -32,7 +39,8 @@ impl Mt1959 { mode, buffer_id, unlocked: false, - speed_table: [0u16; 64], + speed_table: [0u16; 256], + disc_sectors: 0, calibrated: false, } } @@ -117,24 +125,14 @@ impl Mt1959 { } /// Look up optimal read speed for a given LBA from the calibration table. - fn lookup_speed(&self, lba: u32) -> u16 { - if !self.calibrated { + /// Returns speed in KB/s for SET CD SPEED, or 0 if not calibrated. + fn lookup_speed(&self, lba: u32, disc_sectors: u32) -> u16 { + if !self.calibrated || disc_sectors == 0 { return 0; } - let mut best_speed = 0u16; - let mut best_diff = u32::MAX; - for &entry in &self.speed_table { - if entry == 0 { - continue; - } - let entry_lba = entry as u32; - let diff = if lba > entry_lba { lba - entry_lba } else { entry_lba - lba }; - if diff < best_diff { - best_diff = diff; - best_speed = entry; - } - } - best_speed + // Map LBA to zone index (0-255). Probe address space is 0x0000-0xFF00. + let zone = ((lba as u64 * 256) / disc_sectors as u64).min(255) as usize; + self.speed_table[zone] } /// Send SET CD SPEED command. @@ -196,47 +194,57 @@ impl Platform for Mt1959 { } /// - /// Scans disc surface addresses via READ BUFFER sub-command 0x14 to - /// build a 64-entry speed lookup table. Issues SET CD SPEED(max) when done. + /// Probes the disc surface to build a speed profile. Each zone gets + /// an optimal speed in KB/s. The drive firmware returns a speed + /// multiplier (resp[0]) for each probe address. + /// + /// Probe address 0x0000-0xFF00 maps linearly to the disc's LBA range. + /// resp[0] = speed multiplier (e.g. 6 = 6x BD, 12 = 12x BD). fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> { self.ensure_unlocked(scsi)?; self.validate(scsi)?; - // Initial probe: READ BUFFER sub_cmd=0x12 - let cdb = self.read_buffer_sub(0x12, 0, 4); + // Read disc capacity for LBA-to-zone mapping + let cap_cdb = [0x25u8, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let mut cap_buf = [0u8; 8]; + if let Ok(_) = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000) { + self.disc_sectors = u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1; + } + + // Step 1: Calibration init — sub_cmd 0x12 with address 0x0200 + // (primes the firmware for disc surface analysis) + let cdb = self.read_buffer_sub(0x12, 0x0200, 4); let mut resp = [0u8; 4]; let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000); - self.validate(scsi)?; + // Step 2: Raw read primers — read a few sectors with 0x08 flag + // to force the drive to spin up and measure disc characteristics. + // Without these, the speed probes return stale data. + let mut primer_buf = [0u8; 2048]; + let _ = scsi.execute( + &scsi::build_read10_raw(0, 1), DataDirection::FromDevice, &mut primer_buf, 30_000); + let _ = scsi.execute( + &scsi::build_read10_raw(0x200, 1), DataDirection::FromDevice, &mut primer_buf, 30_000); + let _ = scsi.execute( + &scsi::build_read10_raw(0, 1), DataDirection::FromDevice, &mut primer_buf, 30_000); - // Clear speed table - self.speed_table = [0u16; 64]; + // Step 3: Speed probes — sub_cmd 0x14, addresses 0x00 through 0xFF + self.speed_table = [0u16; 256]; - // Scan disc surface — probe addresses up to 0x10000, 256 at a time - let mut table_idx = 0usize; - let mut addr: u32 = 0; - while addr < 0x10000 && table_idx < 64 { - let cdb = self.read_buffer_sub(0x14, addr as u16, 4); + for zone in 0..256u16 { + let cdb = self.read_buffer_sub(0x14, zone, 4); let mut resp = [0u8; 4]; match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) { - Ok(r) if r.bytes_transferred == 4 => { - let val = resp[0]; - if val > 0 { - let speed_entry = ((resp[0] as u16) << 8) | (resp[1] as u16); - if speed_entry > 0 { - self.speed_table[table_idx] = speed_entry; - table_idx += 1; - } - } - addr += 256; + Ok(r) if r.bytes_transferred >= 1 && resp[0] > 0 => { + self.speed_table[zone as usize] = resp[0] as u16 * BD_1X_SPEED; } _ => { - addr += 256; + self.speed_table[zone as usize] = 0xFFFF; } } } - // Set max speed after calibration + // Step 4: Set max speed self.set_cd_speed(scsi, 0xFFFF)?; self.calibrated = true; @@ -305,14 +313,7 @@ impl Platform for Mt1959 { return Err(Error::NotUnlocked); } - // Speed optimization from calibration - if self.calibrated { - let speed = self.lookup_speed(lba); - if speed > 0 { - let _ = self.set_cd_speed(scsi, speed); - } - } - + // No per-read speed changes — calibrate + SET CD SPEED at open_title handles it. // READ(10) with raw flag 0x08 let cdb = scsi::build_read10_raw(lba, count); let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 30_000)?; @@ -323,6 +324,63 @@ impl Platform for Mt1959 { Ok(()) } + /// Continuous speed management — probes zone, reads registers, sets speed. + /// + /// MediaTek drives decay to 1x BD speed (~5 MB/s instead of 15-20 MB/s). + /// + /// Sequence (from strace analysis): + /// 1. Speed probe (sub_cmd 0x14) for current LBA zone + /// 2. Read register A (sub_cmd 0x10 at profile offset A) + /// 3. Read register B (sub_cmd 0x11 at profile offset B) + /// 4. SET CD SPEED: max → zone_speed → max + fn maintain_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> { + if !self.unlocked || self.disc_sectors == 0 { + return Ok(()); + } + + // 1. Probe current zone + let zone = ((lba as u64 * 256) / self.disc_sectors as u64).min(255) as u16; + let cdb = self.read_buffer_sub(0x14, zone, 4); + let mut resp = [0u8; 4]; + let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000); + let zone_speed = if resp[0] > 0 { + resp[0] as u16 * BD_1X_SPEED + } else { + 0xFFFF + }; + + // 2. Read drive registers (handlers 2 & 3) + if self.profile.register_offsets.len() >= 2 { + for i in 0..2 { + let offset = self.profile.register_offsets[i]; + let sub_cmd = 0x10 + i as u8; + let cdb = [ + 0x3C, + self.mode, + self.buffer_id, + sub_cmd, + (offset >> 16) as u8, + (offset >> 8) as u8, + offset as u8, + 0x00, + 0x24, // 36 bytes + 0x00, + ]; + let mut buf = [0u8; 36]; + let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000); + } + } + + // 3. Triple SET CD SPEED: max → zone → max + let _ = self.set_cd_speed(scsi, 0xFFFF); + if zone_speed < 0xFFFF { + let _ = self.set_cd_speed(scsi, zone_speed); + } + let _ = self.set_cd_speed(scsi, 0xFFFF); + + Ok(()) + } + fn is_unlocked(&self) -> bool { self.unlocked } diff --git a/src/udf.rs b/src/udf.rs index f0c25a6..9d0dcbf 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -194,45 +194,94 @@ impl UdfFs { /// Read an Extended File Entry (tag 266) or File Entry (tag 261) /// and return its first allocation extent: (data_lba, data_length). - /// The data_lba is metadata-relative. + /// The data_lba is partition-relative. fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> { + let extents = self.read_icb_extents(session, meta_lba)?; + extents.first().copied().ok_or_else(|| Error::DiscError { + detail: "no allocation descriptors in ICB".into(), + }) + } + + /// Read ALL allocation extents for a file from its ICB. + /// Returns Vec of (partition_relative_lba, byte_length) pairs. + /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents). + fn read_icb_extents(&self, session: &mut DriveSession, meta_lba: u32) -> Result> { let mut icb = [0u8; 2048]; read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?; let tag = u16::from_le_bytes([icb[0], icb[1]]); - // Get allocation descriptor offset based on ICB type - let ad_offset = match tag { + // Get allocation descriptor offset and total length based on ICB type + let (ad_offset, l_ad) = match tag { // Extended File Entry (UDF 2.50, used by BD-ROM) - // Layout: ... L_EA at [208:212], L_AD at [212:216], alloc descs at 216 + L_EA 266 => { let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize; - 216 + l_ea + let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize; + (216 + l_ea, l_ad) } // Standard File Entry - // Layout: ... L_EA at [168:172], L_AD at [172:176], alloc descs at 176 + L_EA 261 => { let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize; - 176 + l_ea + let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; + (176 + l_ea, l_ad) } _ => return Err(Error::DiscError { detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba), }), }; - if ad_offset + 8 > 2048 { - return Err(Error::DiscError { detail: "ICB alloc desc out of range".into() }); + let mut extents = Vec::new(); + let num_descriptors = l_ad / 8; // Short Allocation Descriptor = 8 bytes + + for i in 0..num_descriptors { + let off = ad_offset + i * 8; + if off + 8 > 2048 { + break; // TODO: follow Allocation Extent Descriptors (tag 258) for overflow + } + + let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]); + let extent_type = raw_len >> 30; + let data_len = raw_len & 0x3FFFFFFF; + let data_lba = u32::from_le_bytes([icb[off + 4], icb[off + 5], icb[off + 6], icb[off + 7]]); + + match extent_type { + 0 => extents.push((data_lba, data_len)), // recorded and allocated + 1 => {} // allocated but not recorded (sparse) — skip + 3 => break, // next extent of allocation descriptors — TODO + _ => break, + } } - // Short Allocation Descriptor: extent_length(4) + extent_position(4) - // extent_length upper 2 bits = type (0=recorded, 1=allocated not recorded, 3=next extent) - let raw_len = u32::from_le_bytes([icb[ad_offset], icb[ad_offset + 1], - icb[ad_offset + 2], icb[ad_offset + 3]]); - let data_len = raw_len & 0x3FFFFFFF; - let data_lba = u32::from_le_bytes([icb[ad_offset + 4], icb[ad_offset + 5], - icb[ad_offset + 6], icb[ad_offset + 7]]); + Ok(extents) + } - Ok((data_lba, data_len)) + /// Get all absolute disc sector extents for a file. + /// Returns Vec of (absolute_lba, sector_count) covering the entire file. + pub fn file_extents(&self, session: &mut DriveSession, path: &str) -> Result> { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &self.root; + for part in &parts[..parts.len() - 1] { + current = current.entries.iter().find(|e| { + e.is_dir && e.name.eq_ignore_ascii_case(part) + }).ok_or_else(|| Error::DiscError { + detail: format!("directory not found: {}", part), + })?; + } + let filename = parts.last().unwrap(); + let entry = current.entries.iter().find(|e| { + !e.is_dir && e.name.eq_ignore_ascii_case(filename) + }).ok_or_else(|| Error::DiscError { + detail: format!("file not found: {}", path), + })?; + + let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?; + let mut disc_extents = Vec::new(); + for (lba, byte_len) in alloc_extents { + let abs_lba = self.partition_start + lba; + let sectors = ((byte_len as u64 + 2047) / 2048) as u32; + disc_extents.push((abs_lba, sectors)); + } + Ok(disc_extents) } }