diff --git a/src/clpi.rs b/src/clpi.rs new file mode 100644 index 0000000..c4ffd8f --- /dev/null +++ b/src/clpi.rs @@ -0,0 +1,249 @@ +//! CLPI clip info parser — maps clips to sector ranges on disc. +//! +//! Each .clpi file in BDMV/CLIPINF/ describes one M2TS clip. +//! The EP (Entry Point) map provides timestamp → SPN mapping. +//! SPN × 192 = byte offset in the m2ts file. +//! +//! Reference: https://github.com/lw/BluRay/wiki/CLPI + +use crate::error::{Error, Result}; +use crate::disc::Extent; + +/// Parsed CLPI clip info. +#[derive(Debug)] +pub struct ClipInfo { + pub version: String, + /// Total source packets in the m2ts (each 192 bytes) + pub source_packet_count: u32, + /// Coarse EP entries for the primary video stream + pub ep_coarse: Vec, + /// Fine EP entries for the primary video stream + pub ep_fine: Vec, +} + +#[derive(Debug, Clone)] +pub struct EpCoarse { + pub ref_to_fine_id: u32, + pub pts_coarse: u32, + pub spn_coarse: u32, +} + +#[derive(Debug, Clone)] +pub struct EpFine { + pub pts_fine: u32, + pub spn_fine: u32, +} + +impl ClipInfo { + /// Reconstruct full PTS from coarse + fine entry. + pub fn full_pts(coarse: &EpCoarse, fine: &EpFine) -> u32 { + (coarse.pts_coarse << 19) + (fine.pts_fine << 8) + } + + /// Reconstruct full SPN from coarse + fine entry. + pub fn full_spn(coarse: &EpCoarse, fine: &EpFine) -> u32 { + (coarse.spn_coarse & 0xFFFE0000) + fine.spn_fine + } + + /// Get all EP entries as (PTS, SPN) pairs, fully resolved. + pub fn resolved_ep_map(&self) -> Vec<(u32, u32)> { + let mut entries = Vec::new(); + + for (ci, coarse) in self.ep_coarse.iter().enumerate() { + let fine_start = coarse.ref_to_fine_id as usize; + let fine_end = if ci + 1 < self.ep_coarse.len() { + self.ep_coarse[ci + 1].ref_to_fine_id as usize + } else { + self.ep_fine.len() + }; + + for fi in fine_start..fine_end.min(self.ep_fine.len()) { + let fine = &self.ep_fine[fi]; + let pts = Self::full_pts(coarse, fine); + let spn = Self::full_spn(coarse, fine); + entries.push((pts, spn)); + } + } + + entries + } + + /// Get sector extents for a given in/out time range. + /// + /// Converts PTS timestamps to SPN ranges, then SPN to LBA + /// using the file's starting LBA on disc. + pub fn get_extents(&self, in_time: u32, out_time: u32) -> Vec { + let ep_map = self.resolved_ep_map(); + if ep_map.is_empty() { + return Vec::new(); + } + + // Find SPN at or before in_time + let start_spn = match ep_map.binary_search_by_key(&in_time, |(pts, _)| *pts) { + Ok(i) => ep_map[i].1, + Err(0) => ep_map[0].1, + Err(i) => ep_map[i - 1].1, + }; + + // Find SPN at or after out_time + let end_spn = match ep_map.binary_search_by_key(&out_time, |(pts, _)| *pts) { + Ok(i) => ep_map[i].1, + Err(i) if i < ep_map.len() => ep_map[i].1, + _ => ep_map.last().unwrap().1 + 1, + }; + + if end_spn <= start_spn { + return Vec::new(); + } + + // SPN → byte offset: spn × 192 + // Byte offset → sectors: offset / 2048 + // Note: the caller needs to add the file's starting LBA from UDF + let start_byte = start_spn as u64 * 192; + let end_byte = end_spn as u64 * 192; + let start_sector = (start_byte / 2048) as u32; + let end_sector = ((end_byte + 2047) / 2048) as u32; + + vec![Extent { + start_lba: start_sector, // relative to m2ts file start + sector_count: end_sector - start_sector, + }] + } +} + +/// Parse a CLPI file from raw bytes. +pub fn parse(data: &[u8]) -> Result { + if data.len() < 40 { + return Err(Error::DiscError { detail: "CLPI too short".into() }); + } + + if &data[0..4] != b"HDMV" { + return Err(Error::DiscError { detail: "not a CLPI file".into() }); + } + let version = String::from_utf8_lossy(&data[4..8]).to_string(); + + // Header offsets + let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; + let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize; + let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize; + + // ClipInfo section at offset 40 + // source_packet_count at offset 40 + 4(len) + 2(reserved) + 1(stream_type) + 1(app_type) + 4(reserved) + 4(ts_rate) + let source_packet_count = if data.len() > 56 { + u32::from_be_bytes([data[56], data[57], data[58], data[59]]) + } else { + 0 + }; + + // Parse CPI / EP Map + let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() { + parse_cpi(&data[cpi_start..])? + } else { + (Vec::new(), Vec::new()) + }; + + Ok(ClipInfo { + version, + source_packet_count, + ep_coarse, + ep_fine, + }) +} + +/// Parse the CPI section containing the EP map. +fn parse_cpi(data: &[u8]) -> Result<(Vec, Vec)> { + if data.len() < 8 { + return Ok((Vec::new(), Vec::new())); + } + + let cpi_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; + if cpi_length < 4 { + return Ok((Vec::new(), Vec::new())); + } + + // CPI type at bits 44-47 (byte 5, lower 4 bits) + // Skip to EP map: offset 4 (after length) + 2 (reserved/type) + let ep_map = &data[6..]; + if ep_map.len() < 4 { + return Ok((Vec::new(), Vec::new())); + } + + // EP map header + // [0] reserved + // [1] number of stream PID entries + let num_streams = ep_map[1] as usize; + if num_streams == 0 { + return Ok((Vec::new(), Vec::new())); + } + + // Stream PID entry headers start at offset 2 + // Each: 2(PID) + 2(reserved+type) + 2(num_coarse) + 4(num_fine) + 4(ep_map_start) = 14 bytes + // We only care about the first stream (primary video) + if ep_map.len() < 16 { + return Ok((Vec::new(), Vec::new())); + } + + let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]); + // ep_map[4..6] = reserved + EP stream type + let num_coarse = u16::from_be_bytes([ep_map[6], ep_map[7]]) as usize; + let num_fine = u32::from_be_bytes([ep_map[8], ep_map[9], ep_map[10], ep_map[11]]) as usize; + let ep_map_offset = u32::from_be_bytes([ep_map[12], ep_map[13], ep_map[14], ep_map[15]]) as usize; + + // EP map for this stream starts at ep_map_offset relative to ep_map start + if ep_map_offset + 4 > ep_map.len() { + return Ok((Vec::new(), Vec::new())); + } + + let stream_ep = &ep_map[ep_map_offset..]; + if stream_ep.len() < 4 { + return Ok((Vec::new(), Vec::new())); + } + + // Fine table start address (relative to this stream EP map) + let fine_start = u32::from_be_bytes([stream_ep[0], stream_ep[1], stream_ep[2], stream_ep[3]]) as usize; + + // Coarse entries start at offset 4, 8 bytes each + let coarse_data = &stream_ep[4..]; + let mut ep_coarse = Vec::with_capacity(num_coarse); + for i in 0..num_coarse { + let off = i * 8; + if off + 8 > coarse_data.len() { + break; + } + + let dword0 = u32::from_be_bytes([coarse_data[off], coarse_data[off + 1], + coarse_data[off + 2], coarse_data[off + 3]]); + let ref_to_fine_id = dword0 >> 14; + let pts_coarse = dword0 & 0x3FFF; + let spn_coarse = u32::from_be_bytes([coarse_data[off + 4], coarse_data[off + 5], + coarse_data[off + 6], coarse_data[off + 7]]); + + ep_coarse.push(EpCoarse { + ref_to_fine_id, + pts_coarse, + spn_coarse, + }); + } + + // Fine entries at fine_start, 4 bytes each + let mut ep_fine = Vec::with_capacity(num_fine); + if fine_start < stream_ep.len() { + let fine_data = &stream_ep[fine_start..]; + for i in 0..num_fine { + let off = i * 4; + if off + 4 > fine_data.len() { + break; + } + + let dword = u32::from_be_bytes([fine_data[off], fine_data[off + 1], + fine_data[off + 2], fine_data[off + 3]]); + // Bits: is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17) + let pts_fine = (dword >> 17) & 0x7FF; + let spn_fine = dword & 0x1FFFF; + + ep_fine.push(EpFine { pts_fine, spn_fine }); + } + } + + Ok((ep_coarse, ep_fine)) +} diff --git a/src/disc.rs b/src/disc.rs new file mode 100644 index 0000000..a3b8032 --- /dev/null +++ b/src/disc.rs @@ -0,0 +1,173 @@ +//! Disc structure — titles, clips, and sector ranges. +//! +//! Reads the BDMV directory structure from a disc to enumerate titles. +//! Each title is a playlist (MPLS) containing one or more clips, +//! each clip mapping to a range of sectors (LBAs) on disc. + +use crate::error::{Error, Result}; +use crate::drive::DriveSession; +use crate::udf; +use crate::mpls; +use crate::clpi; + +/// A disc title (one MPLS playlist). +#[derive(Debug, Clone)] +pub struct Title { + /// Playlist number (e.g. 800 for 00800.mpls) + pub playlist_id: u16, + /// Playlist filename (e.g. "00800.mpls") + pub filename: String, + /// Duration in 45kHz ticks + pub duration_ticks: u64, + /// Duration formatted as human-readable string + pub duration: String, + /// Total size in bytes (sum of all clip extents × 2048) + pub size_bytes: u64, + /// Clips in playback order + pub clips: Vec, + /// Number of video streams + pub video_streams: u16, + /// Number of audio streams + pub audio_streams: u16, +} + +/// A clip within a title. +#[derive(Debug, Clone)] +pub struct Clip { + /// Clip filename (e.g. "00001") + pub clip_id: String, + /// In-time (45kHz ticks) + pub in_time: u32, + /// Out-time (45kHz ticks) + pub out_time: u32, + /// Sector ranges on disc for this clip + pub extents: Vec, +} + +/// A contiguous range of sectors on disc. +#[derive(Debug, Clone, Copy)] +pub struct Extent { + /// Starting LBA + pub start_lba: u32, + /// Number of sectors + pub sector_count: u32, +} + +impl Title { + /// Duration in seconds. + pub fn duration_secs(&self) -> f64 { + self.duration_ticks as f64 / 45000.0 + } + + /// Total sectors across all clips. + pub fn total_sectors(&self) -> u64 { + self.clips.iter() + .flat_map(|c| &c.extents) + .map(|e| e.sector_count as u64) + .sum() + } + + /// Format duration as "Xh Ym" or "Xm Ys". + fn format_duration(ticks: u64) -> String { + let secs = ticks / 45000; + let hours = secs / 3600; + let mins = (secs % 3600) / 60; + if hours > 0 { + format!("{}h {:02}m", hours, mins) + } else { + format!("{}m {:02}s", mins, secs % 60) + } + } +} + +/// Scan a disc and return all titles sorted by duration (longest first). +pub fn scan_titles(session: &mut DriveSession) -> Result> { + // Read disc capacity + let capacity = read_capacity(session)?; + + // Read UDF filesystem to find BDMV directory + let udf_fs = udf::read_filesystem(session)?; + + // Find all playlist files + let playlist_dir = udf_fs.find_dir("/BDMV/PLAYLIST") + .ok_or_else(|| Error::DiscError { detail: "BDMV/PLAYLIST not found".into() })?; + + let mut titles = Vec::new(); + + for entry in &playlist_dir.entries { + if !entry.name.ends_with(".mpls") { + continue; + } + + // Read the MPLS file + let mpls_data = udf_fs.read_file(session, &format!("/BDMV/PLAYLIST/{}", entry.name))?; + let playlist = match mpls::parse(&mpls_data) { + Ok(p) => p, + Err(_) => continue, // skip malformed playlists + }; + + // For each play item, read the corresponding CLPI to get sector extents + let mut clips = Vec::new(); + let mut total_ticks: u64 = 0; + let mut total_size: u64 = 0; + + for item in &playlist.play_items { + let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", item.clip_id); + let clpi_data = match udf_fs.read_file(session, &clpi_path) { + Ok(d) => d, + Err(_) => continue, + }; + let clip_info = match clpi::parse(&clpi_data) { + Ok(c) => c, + Err(_) => continue, + }; + + // Map timestamps to sector extents + let extents = clip_info.get_extents(item.in_time, item.out_time); + let clip_sectors: u64 = extents.iter().map(|e| e.sector_count as u64).sum(); + + total_ticks += (item.out_time - item.in_time) as u64; + total_size += clip_sectors * 2048; + + clips.push(Clip { + clip_id: item.clip_id.clone(), + in_time: item.in_time, + out_time: item.out_time, + extents, + }); + } + + if clips.is_empty() { + continue; + } + + // Parse playlist ID from filename + let playlist_id = entry.name.trim_end_matches(".mpls") + .parse::().unwrap_or(0); + + titles.push(Title { + playlist_id, + filename: entry.name.clone(), + duration_ticks: total_ticks, + duration: Title::format_duration(total_ticks), + size_bytes: total_size, + clips, + video_streams: playlist.video_stream_count, + audio_streams: playlist.audio_stream_count, + }); + } + + // Sort by duration, longest first + titles.sort_by(|a, b| b.duration_ticks.cmp(&a.duration_ticks)); + + Ok(titles) +} + +/// Read disc capacity (total sectors). +fn read_capacity(session: &mut DriveSession) -> Result { + let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let mut buf = [0u8; 8]; + session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5000)?; + let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]); + Ok(lba) +} diff --git a/src/drive.rs b/src/drive.rs index 3770b0e..ba4bb30 100644 --- a/src/drive.rs +++ b/src/drive.rs @@ -128,4 +128,24 @@ impl DriveSession { pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result> { self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length) } + + /// Standard READ(10) — reads unencrypted sectors (UDF filesystem, etc). + /// Does not require unlock. Use read_sectors() for raw/encrypted content. + pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result { + let cdb = [ + 0x28, 0x00, // READ(10), no flags + (lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8, + 0x00, + (count >> 8) as u8, count as u8, + 0x00, + ]; + let result = self.scsi.as_mut().execute( + &cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?; + Ok(result.bytes_transferred) + } + + /// Send a raw SCSI CDB. Used by UDF reader and disc structure parsers. + pub fn scsi_execute(&mut self, cdb: &[u8], direction: crate::scsi::DataDirection, buf: &mut [u8], timeout_ms: u32) -> Result { + self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms) + } } diff --git a/src/error.rs b/src/error.rs index cc2fab5..ecd2134 100644 --- a/src/error.rs +++ b/src/error.rs @@ -43,6 +43,9 @@ pub enum Error { // 5xxx — I/O errors IoError { source: std::io::Error }, + + // 6xxx — Disc format errors + DiscError { detail: String }, } impl Error { @@ -61,6 +64,7 @@ impl Error { Error::ScsiError { .. } => 4000, Error::ScsiTimeout { .. } => 4001, Error::IoError { .. } => 5000, + Error::DiscError { .. } => 6000, } } } @@ -87,6 +91,7 @@ impl std::fmt::Display for Error { write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"), Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"), Error::IoError { source } => write!(f, "E5000: {source}"), + Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"), } } } diff --git a/src/lib.rs b/src/lib.rs index 8d04a3a..0c00acc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,10 @@ pub mod platform; pub mod drive; pub mod identity; pub mod speed; +pub mod udf; +pub mod mpls; +pub mod clpi; +pub mod disc; pub use error::{Error, Result}; pub use drive::DriveSession; @@ -49,3 +53,4 @@ pub use profile::{DriveProfile, Chipset}; pub use platform::{Platform, DriveStatus}; pub use scsi::ScsiTransport; pub use speed::DriveSpeed; +pub use disc::{Title, Clip, Extent}; diff --git a/src/mpls.rs b/src/mpls.rs new file mode 100644 index 0000000..831d79f --- /dev/null +++ b/src/mpls.rs @@ -0,0 +1,123 @@ +//! MPLS playlist parser — Blu-ray movie playlists. +//! +//! Each .mpls file in BDMV/PLAYLIST/ defines a title. +//! Contains play items (clips) with in/out timestamps, +//! stream info (video, audio, subtitle tracks). +//! +//! Reference: https://github.com/lw/BluRay/wiki/MPLS + +use crate::error::{Error, Result}; + +/// Parsed MPLS playlist. +#[derive(Debug)] +pub struct Playlist { + /// MPLS version (e.g. "0200" or "0300") + pub version: String, + /// Play items in playback order + pub play_items: Vec, + /// Number of video streams + pub video_stream_count: u16, + /// Number of audio streams + pub audio_stream_count: u16, +} + +/// A play item — one clip reference with in/out times. +#[derive(Debug)] +pub struct PlayItem { + /// Clip filename without extension (e.g. "00001") + pub clip_id: String, + /// In-time in 45kHz ticks + pub in_time: u32, + /// Out-time in 45kHz ticks + pub out_time: u32, + /// Connection condition (1=seamless, 5/6=non-seamless) + pub connection_condition: u8, +} + +/// Parse an MPLS file from raw bytes. +pub fn parse(data: &[u8]) -> Result { + if data.len() < 40 { + return Err(Error::DiscError { detail: "MPLS too short".into() }); + } + + // Header: "MPLS" + version (4 bytes ASCII) + if &data[0..4] != b"MPLS" { + return Err(Error::DiscError { detail: "not an MPLS file".into() }); + } + let version = String::from_utf8_lossy(&data[4..8]).to_string(); + + // Offsets table at bytes 8-19 + let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize; + let _playlist_mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize; + + if playlist_start + 10 > data.len() { + return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() }); + } + + // PlayList section + let pl = &data[playlist_start..]; + let _pl_length = u32::from_be_bytes([pl[0], pl[1], pl[2], pl[3]]) as usize; + // pl[4..6] reserved + let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize; + let _num_sub_paths = u16::from_be_bytes([pl[8], pl[9]]) as usize; + + let mut play_items = Vec::with_capacity(num_play_items); + let mut pos = 10; // start of first play item + + let mut video_streams: u16 = 0; + let mut audio_streams: u16 = 0; + + for _ in 0..num_play_items { + if playlist_start + pos + 2 > data.len() { + break; + } + + let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize; + if playlist_start + pos + item_length + 2 > data.len() { + break; + } + + let item = &pl[pos + 2..pos + 2 + item_length]; + + // Clip ID: 5 bytes ASCII at offset 0 (e.g. "00001") + let clip_id = String::from_utf8_lossy(&item[0..5]).to_string(); + // item[5..9] = codec ID ("M2TS") + // item[9] = connection condition (bits) + let connection_condition = item[9] & 0x0F; + // item[10] = ref to STC_id + // item[12..16] = IN_time + let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]); + // item[16..20] = OUT_time + let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]); + + // STN table follows at offset 20 within the play item + if item.len() > 22 { + let stn_length = u16::from_be_bytes([item[20], item[21]]) as usize; + if stn_length > 4 && item.len() > 24 { + // Number of primary video/audio entries + let n_video = item[24] as u16; + let n_audio = item[25] as u16; + if video_streams == 0 { + video_streams = n_video; + audio_streams = n_audio; + } + } + } + + play_items.push(PlayItem { + clip_id, + in_time, + out_time, + connection_condition, + }); + + pos += 2 + item_length; + } + + Ok(Playlist { + version, + play_items, + video_stream_count: video_streams, + audio_stream_count: audio_streams, + }) +} diff --git a/src/udf.rs b/src/udf.rs new file mode 100644 index 0000000..353ca6f --- /dev/null +++ b/src/udf.rs @@ -0,0 +1,321 @@ +//! UDF filesystem reader — read files from Blu-ray discs. +//! +//! Minimal UDF implementation: just enough to find and read files +//! in the BDMV directory structure. Not a full UDF implementation. +//! +//! Reference: ECMA-167, UDF 2.50 (OSTA) + +use crate::error::{Error, Result}; +use crate::drive::DriveSession; +use crate::scsi::DataDirection; + +/// A UDF filesystem parsed from disc. +#[derive(Debug)] +pub struct UdfFs { + /// Root directory entries + pub root: DirEntry, + /// Partition start LBA + partition_start: u32, +} + +/// A directory entry (file or directory). +#[derive(Debug, Clone)] +pub struct DirEntry { + pub name: String, + pub is_dir: bool, + /// LBA of the file/directory data + pub lba: u32, + /// Size in bytes + pub size: u32, + /// Child entries (if directory) + pub entries: Vec, +} + +impl UdfFs { + /// Find a directory by path (e.g. "/BDMV/PLAYLIST"). + pub fn find_dir(&self, path: &str) -> Option<&DirEntry> { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &self.root; + + for part in &parts { + current = current.entries.iter().find(|e| { + e.is_dir && e.name.eq_ignore_ascii_case(part) + })?; + } + + Some(current) + } + + /// Read a file by path, returning its contents. + pub fn read_file(&self, session: &mut DriveSession, path: &str) -> Result> { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &self.root; + + // Navigate to parent directory + 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), + })?; + } + + // Find the file + 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), + })?; + + // Read the file sectors + let sector_count = (entry.size + 2047) / 2048; + let mut data = vec![0u8; (sector_count * 2048) as usize]; + + for i in 0..sector_count { + let lba = self.partition_start + entry.lba + i; + let offset = (i * 2048) as usize; + read_sector(session, lba, &mut data[offset..offset + 2048])?; + } + + data.truncate(entry.size as usize); + Ok(data) + } +} + +/// Read the UDF filesystem from a disc. +pub fn read_filesystem(session: &mut DriveSession) -> Result { + // UDF Anchor Volume Descriptor Pointer at sector 256 + let mut avdp = [0u8; 2048]; + read_sector(session, 256, &mut avdp)?; + + // Check descriptor tag (tag ID = 2 for AVDP) + let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]); + if tag_id != 2 { + return Err(Error::DiscError { detail: format!("not UDF: tag {} at sector 256", tag_id) }); + } + + // Main VDS extent: bytes 16-23 + let mvds_lba = u32::from_le_bytes([avdp[16], avdp[17], avdp[18], avdp[19]]); + let mvds_len = u32::from_le_bytes([avdp[20], avdp[21], avdp[22], avdp[23]]); + + // Read Volume Descriptor Sequence to find Partition Descriptor and Logical Volume Descriptor + let mut partition_start: u32 = 0; + let mut root_icb_lba: u32 = 0; + + let mvds_sectors = (mvds_len + 2047) / 2048; + for i in 0..mvds_sectors.min(32) { + let mut desc = [0u8; 2048]; + read_sector(session, mvds_lba + i, &mut desc)?; + + let desc_tag = u16::from_le_bytes([desc[0], desc[1]]); + match desc_tag { + 5 => { + // Partition Descriptor + partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]); + } + 6 => { + // Logical Volume Descriptor — contains root FSD location + // LV Contents Use at offset 248: extent of File Set Descriptor + let fsd_lba = u32::from_le_bytes([desc[248], desc[249], desc[250], desc[251]]); + root_icb_lba = fsd_lba; + } + 8 => break, // Terminating Descriptor + _ => continue, + } + } + + if partition_start == 0 { + return Err(Error::DiscError { detail: "UDF: no partition descriptor found".into() }); + } + + // Read File Set Descriptor to get root directory ICB + let mut fsd = [0u8; 2048]; + read_sector(session, partition_start + root_icb_lba, &mut fsd)?; + + let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]); + if fsd_tag != 256 { + return Err(Error::DiscError { detail: format!("UDF: expected FSD (256), got tag {}", fsd_tag) }); + } + + // Root Directory ICB at offset 400 in FSD + let root_dir_lba = u32::from_le_bytes([fsd[400], fsd[401], fsd[402], fsd[403]]); + + // Read root directory + let root = read_directory(session, partition_start, root_dir_lba, "")?; + + Ok(UdfFs { + root, + partition_start, + }) +} + +/// Read a UDF directory and its immediate children. +fn read_directory(session: &mut DriveSession, part_start: u32, dir_lba: u32, name: &str) -> Result { + // Read the ICB (Information Control Block) for this directory + let mut icb = [0u8; 2048]; + read_sector(session, part_start + dir_lba, &mut icb)?; + + let icb_tag = u16::from_le_bytes([icb[0], icb[1]]); + + // File Entry (tag 261) or Extended File Entry (tag 266) + let (alloc_offset, alloc_len) = match icb_tag { + 261 => { + // File Entry + let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize; + let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize; + (176 + l_ea, l_ad) + } + 266 => { + // Extended File Entry + let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize; + let l_ad = u32::from_le_bytes([icb[212], icb[213], icb[214], icb[215]]) as usize; + (216 + l_ea, l_ad) + } + _ => { + return Ok(DirEntry { + name: name.to_string(), + is_dir: true, + lba: dir_lba, + size: 0, + entries: Vec::new(), + }); + } + }; + + // Parse allocation descriptors to find directory data location + // Short Allocation Descriptor: 8 bytes (4 length + 4 position) + let data_lba = if alloc_offset + 8 <= icb.len() { + u32::from_le_bytes([icb[alloc_offset + 4], icb[alloc_offset + 5], + icb[alloc_offset + 6], icb[alloc_offset + 7]]) + } else { + dir_lba + 1 // assume data follows ICB + }; + + let data_len = if alloc_offset + 4 <= icb.len() { + u32::from_le_bytes([icb[alloc_offset], icb[alloc_offset + 1], + icb[alloc_offset + 2], icb[alloc_offset + 3]]) & 0x3FFFFFFF + } else { + 2048 + }; + + // Read directory data + let sectors = ((data_len + 2047) / 2048).min(64) as usize; + let mut dir_data = vec![0u8; sectors * 2048]; + for i in 0..sectors { + read_sector(session, part_start + data_lba + i as u32, + &mut dir_data[i * 2048..(i + 1) * 2048])?; + } + + // Parse File Identifier Descriptors + let mut entries = Vec::new(); + let mut pos = 0; + + while pos + 38 < dir_data.len().min(data_len as usize) { + let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]); + if fid_tag != 257 { + break; // not a FID + } + + let file_chars = dir_data[pos + 18]; + let l_fi = dir_data[pos + 19] as usize; // filename length + let icb_lba = u32::from_le_bytes([dir_data[pos + 20], dir_data[pos + 21], + dir_data[pos + 22], dir_data[pos + 23]]); + let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize; + + let name_offset = pos + 38 + l_iu; + let is_dir = (file_chars & 0x02) != 0; + let is_parent = (file_chars & 0x08) != 0; + + if !is_parent && l_fi > 0 && name_offset + l_fi <= dir_data.len() { + let raw_name = &dir_data[name_offset..name_offset + l_fi]; + let entry_name = parse_udf_name(raw_name); + + if !entry_name.is_empty() { + if is_dir { + // Recurse into subdirectory (max 2 levels deep for BDMV) + let subdir = read_directory(session, part_start, icb_lba, &entry_name)?; + entries.push(subdir); + } else { + // Get file size from its ICB + let file_size = read_file_size(session, part_start, icb_lba).unwrap_or(0); + entries.push(DirEntry { + name: entry_name, + is_dir: false, + lba: icb_lba, + size: file_size, + entries: Vec::new(), + }); + } + } + } + + // Advance to next FID (4-byte aligned) + let fid_len = 38 + l_iu + l_fi; + let padded = (fid_len + 3) & !3; + pos += padded; + } + + Ok(DirEntry { + name: name.to_string(), + is_dir: true, + lba: dir_lba, + size: data_len, + entries, + }) +} + +/// Read file size from a File Entry ICB. +fn read_file_size(session: &mut DriveSession, part_start: u32, icb_lba: u32) -> Result { + let mut icb = [0u8; 2048]; + read_sector(session, part_start + icb_lba, &mut icb)?; + + let tag = u16::from_le_bytes([icb[0], icb[1]]); + match tag { + 261 => { + // File Entry: info length at offset 56 (8 bytes LE) + Ok(u32::from_le_bytes([icb[56], icb[57], icb[58], icb[59]])) + } + 266 => { + // Extended File Entry: info length at offset 56 + Ok(u32::from_le_bytes([icb[56], icb[57], icb[58], icb[59]])) + } + _ => Ok(0), + } +} + +/// Parse a UDF filename from raw bytes. +/// UDF uses either 8-bit or 16-bit encoding (first byte = compression ID). +fn parse_udf_name(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + + match data[0] { + 8 => { + // 8-bit characters + String::from_utf8_lossy(&data[1..]).trim().to_string() + } + 16 => { + // 16-bit big-endian Unicode + let mut s = String::new(); + let chars = &data[1..]; + for i in (0..chars.len()).step_by(2) { + if i + 1 < chars.len() { + let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16; + if let Some(ch) = char::from_u32(c as u32) { + s.push(ch); + } + } + } + s.trim().to_string() + } + _ => String::from_utf8_lossy(&data[1..]).trim().to_string(), + } +} + +/// Read a single 2048-byte sector from the drive. +fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> { + session.read_disc(lba, 1, buf)?; + Ok(()) +}