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:
+249
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user