Add disc format parsers: UDF, MPLS, CLPI

- udf.rs: read files from Blu-ray disc filesystem
- mpls.rs: parse playlists → titles with clips and timestamps
- clpi.rs: parse clip info → EP map with coarse/fine SPN entries
- disc.rs: high-level title scanning, sector extent mapping
- drive.rs: add read_disc() for unencrypted reads, scsi_execute()
- error.rs: add E6000 DiscError

Stage 1 of freemkv rip: identify titles and their sector ranges.
This commit is contained in:
MattJackson
2026-04-06 14:27:48 -07:00
parent 3f083f57de
commit 7db4606038
7 changed files with 896 additions and 0 deletions
+249
View File
@@ -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<EpCoarse>,
/// Fine EP entries for the primary video stream
pub ep_fine: Vec<EpFine>,
}
#[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<Extent> {
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<ClipInfo> {
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<EpCoarse>, Vec<EpFine>)> {
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))
}
+173
View File
@@ -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<Clip>,
/// 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<Extent>,
}
/// 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<Vec<Title>> {
// 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::<u16>().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<u32> {
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)
}
+20
View File
@@ -128,4 +128,24 @@ impl DriveSession {
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> { pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length) 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<usize> {
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<crate::scsi::ScsiResult> {
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
}
} }
+5
View File
@@ -43,6 +43,9 @@ pub enum Error {
// 5xxx — I/O errors // 5xxx — I/O errors
IoError { source: std::io::Error }, IoError { source: std::io::Error },
// 6xxx — Disc format errors
DiscError { detail: String },
} }
impl Error { impl Error {
@@ -61,6 +64,7 @@ impl Error {
Error::ScsiError { .. } => 4000, Error::ScsiError { .. } => 4000,
Error::ScsiTimeout { .. } => 4001, Error::ScsiTimeout { .. } => 4001,
Error::IoError { .. } => 5000, 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}"), 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::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"),
Error::IoError { source } => write!(f, "E5000: {source}"), Error::IoError { source } => write!(f, "E5000: {source}"),
Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"),
} }
} }
} }
+5
View File
@@ -41,6 +41,10 @@ pub mod platform;
pub mod drive; pub mod drive;
pub mod identity; pub mod identity;
pub mod speed; pub mod speed;
pub mod udf;
pub mod mpls;
pub mod clpi;
pub mod disc;
pub use error::{Error, Result}; pub use error::{Error, Result};
pub use drive::DriveSession; pub use drive::DriveSession;
@@ -49,3 +53,4 @@ pub use profile::{DriveProfile, Chipset};
pub use platform::{Platform, DriveStatus}; pub use platform::{Platform, DriveStatus};
pub use scsi::ScsiTransport; pub use scsi::ScsiTransport;
pub use speed::DriveSpeed; pub use speed::DriveSpeed;
pub use disc::{Title, Clip, Extent};
+123
View File
@@ -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<PlayItem>,
/// 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<Playlist> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "MPLS too short".into() });
}
// Header: "MPLS" + version (4 bytes ASCII)
if &data[0..4] != b"MPLS" {
return Err(Error::DiscError { detail: "not an MPLS file".into() });
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
// Offsets table at bytes 8-19
let 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,
})
}
+321
View File
@@ -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<DirEntry>,
}
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<Vec<u8>> {
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<UdfFs> {
// 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<DirEntry> {
// 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<u32> {
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(())
}