Compare commits

...
7 Commits
Author SHA1 Message Date
MattJackson dd0f49fa3d v0.3.0: disc format parsers (UDF 2.50, MPLS, CLPI), typed stream API 2026-04-06 15:50:56 -07:00
MattJackson 7d3284cf28 UDF: file data on physical partition, disc API with typed streams 2026-04-06 15:50:41 -07:00
MattJackson 117a7823d6 Library disc API: Disc, Title, Stream with typed codec/hdr/color
Clean public API for disc scanning. CLI should only display,
never parse binary formats directly.

Disc::scan() → titles → streams with:
 codec (Hevc/TrueHd/Ac3/Pgs/etc), pid, language,
 resolution, frame_rate, channels, hdr, color_space,
 secondary flag, label
2026-04-06 15:47:07 -07:00
MattJackson 7db4606038 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.
2026-04-06 14:27:48 -07:00
MattJackson 3f083f57de Fix SG_IO type for musl compatibility 2026-04-06 12:55:30 -07:00
MattJackson 326edc4e83 Auto-update README on release 2026-04-06 12:50:47 -07:00
MattJackson 1e90497760 README: consistent layout, remove prebuilt binary section (library only) 2026-04-06 12:33:20 -07:00
11 changed files with 1178 additions and 41 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Update README version
on:
release:
types: [published]
permissions:
contents: write
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
- name: Update version in README
run: |
VERSION="${{ github.event.release.tag_name }}"
FILE="README.md"
# Update cargo dependency version (e.g. "0.2" -> "0.3")
MAJOR_MINOR="${VERSION#v}"
MAJOR_MINOR="${MAJOR_MINOR%.*}"
sed -i "s|libfreemkv = \"[0-9]*\.[0-9]*\"|libfreemkv = \"${MAJOR_MINOR}\"|" "$FILE"
- name: Commit and push
run: |
VERSION="${{ github.event.release.tag_name }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git diff --cached --quiet || git commit -m "Update to libfreemkv ${VERSION}"
git push
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.2.1"
version = "0.3.0"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
+15 -38
View File
@@ -10,23 +10,12 @@ Part of the [freemkv](https://github.com/freemkv) project.
## Install
### As a library
```toml
[dependencies]
libfreemkv = "0.2"
```
### Prebuilt binaries
Download from [GitHub Releases](https://github.com/freemkv/libfreemkv/releases):
| Platform | Architecture | Download |
|----------|-------------|----------|
| Linux | x86_64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
| Linux | aarch64 | [freemkv-info](https://github.com/freemkv/libfreemkv/releases/latest) |
## Usage
## Quick Start
```rust
use libfreemkv::DriveSession;
@@ -54,12 +43,11 @@ No fingerprints. No encrypted lookups. All identification uses standard SCSI fie
## API
```rust
// Open and auto-identify
let mut session = DriveSession::open(device_path)?;
// Drive identity
session.drive_id.vendor_id // "HL-DT-ST"
session.drive_id.product_id // "BD-RE BU40N"
session.drive_id.vendor_id // "HL-DT-ST"
session.drive_id.product_id // "BD-RE BU40N"
session.drive_id.product_revision // "1.03"
session.drive_id.vendor_specific // "NM00000"
session.drive_id.firmware_date // "211810241934"
@@ -71,12 +59,12 @@ session.profile.unlock_buf_id // 0x44
session.profile.signature // [0x99, 0x9e, 0xc3, 0x75]
// Operations
session.unlock()?; // activate raw mode
session.calibrate()?; // speed optimization
session.unlock()?;
session.calibrate()?;
session.read_sectors(lba, count, &mut buf)?;
session.status()?; // feature flags
session.read_config()?; // drive configuration
session.read_register(index)?; // hardware registers
session.status()?;
session.read_config()?;
session.read_register(index)?;
```
## Chipset Support
@@ -86,8 +74,6 @@ session.read_register(index)?; // hardware registers
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
| Renesas | Planned | -- | Pioneer |
Each profile stores per-drive `unlock_mode` and `unlock_buf_id` — the exact CDB bytes for that drive's unlock command. A single `Mt1959` implementation handles all MediaTek variants.
## Drive Profile
Each bundled profile (compiled into the binary):
@@ -109,35 +95,26 @@ Each bundled profile (compiled into the binary):
## Error Codes
Structured errors for programmatic handling — no user-facing English text in the library.
| Code | Error | Meaning |
|------|-------|---------|
| E1000 | DeviceNotFound | Device path doesn't exist |
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
| E2000 | UnsupportedDrive | No matching profile |
| E2001 | ProfileNotFound | Profile lookup failed |
| E3000 | UnlockFailed | Unlock command rejected |
| E3001 | SignatureMismatch | Response signature wrong |
| E3002 | NotUnlocked | Operation requires unlock first |
| E4000 | ScsiError | SCSI command failed |
| E5000 | IoError | System I/O error |
## Architecture
```
DriveSession
├── ScsiTransport SG_IO (Linux) / IOKit (macOS, planned)
├── DriveProfile per-drive parameters (bundled JSON, compiled in)
├── DriveId INQUIRY + GET_CONFIG 010C fields
└── Platform
├── Mt1959 MediaTek MT1959 (206 drives)
└── (Renesas) Pioneer (planned)
```
## Platform
Linux only today (SG_IO ioctl). The `ScsiTransport` trait abstracts the platform — macOS IOKit and Windows SPTI backends are planned.
| Platform | Status | Backend |
|----------|--------|---------|
| Linux | Supported | SG_IO ioctl |
| macOS | Planned | IOKit |
| Windows | Planned | SPTI |
The `ScsiTransport` trait abstracts the platform. Adding a backend is one file behind a `cfg` gate.
## Contributing
+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))
}
+261
View File
@@ -0,0 +1,261 @@
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
//!
//! This is the high-level API for disc content. The CLI calls this,
//! never parses MPLS/CLPI/UDF directly.
//!
//! Usage:
//! let disc = Disc::scan(&mut session)?;
//! for title in disc.titles() { ... }
//! for stream in title.streams() { ... }
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::udf;
use crate::mpls;
use crate::clpi;
// ─── Public types ───────────────────────────────────────────────────────────
/// A scanned Blu-ray disc.
#[derive(Debug)]
pub struct Disc {
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<Title>,
}
/// A title (one MPLS playlist).
#[derive(Debug, Clone)]
pub struct Title {
/// Playlist filename (e.g. "00800.mpls")
pub playlist: String,
/// Playlist number (e.g. 800)
pub playlist_id: u16,
/// Duration in seconds
pub duration_secs: f64,
/// Total size in bytes
pub size_bytes: u64,
/// Number of clips
pub clip_count: usize,
/// All streams (video, audio, subtitle, etc.)
pub streams: Vec<Stream>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
}
/// A stream within a title.
#[derive(Debug, Clone)]
pub struct Stream {
/// Stream type
pub kind: StreamKind,
/// MPEG-TS packet ID
pub pid: u16,
/// Codec
pub codec: Codec,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Video resolution (e.g. "2160p", "1080p")
pub resolution: String,
/// Frame rate (e.g. "23.976")
pub frame_rate: String,
/// Channel layout (e.g. "5.1", "7.1", "stereo")
pub channels: String,
/// Sample rate (e.g. "48kHz")
pub sample_rate: String,
/// HDR format
pub hdr: HdrFormat,
/// Color space
pub color_space: ColorSpace,
/// Whether this is a secondary/enhancement stream
pub secondary: bool,
/// Extra label (e.g. "Dolby Vision EL")
pub label: String,
}
/// Stream type.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StreamKind {
Video,
Audio,
Subtitle,
}
/// Video/audio codec.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Codec {
// Video
Hevc,
H264,
Vc1,
Mpeg2,
// Audio
TrueHd,
DtsHdMa,
DtsHdHr,
Dts,
Ac3,
Ac3Plus,
Lpcm,
// Subtitle
Pgs,
// Unknown
Unknown(u8),
}
/// HDR format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum HdrFormat {
Sdr,
Hdr10,
DolbyVision,
}
/// Color space.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColorSpace {
Bt709,
Bt2020,
Unknown,
}
/// A contiguous range of sectors on disc.
#[derive(Debug, Clone, Copy)]
pub struct Extent {
pub start_lba: u32,
pub sector_count: u32,
}
// ─── Display helpers ────────────────────────────────────────────────────────
impl Codec {
pub fn name(&self) -> &'static str {
match self {
Codec::Hevc => "HEVC",
Codec::H264 => "H.264",
Codec::Vc1 => "VC-1",
Codec::Mpeg2 => "MPEG-2",
Codec::TrueHd => "TrueHD",
Codec::DtsHdMa => "DTS-HD MA",
Codec::DtsHdHr => "DTS-HD HR",
Codec::Dts => "DTS",
Codec::Ac3 => "AC-3",
Codec::Ac3Plus => "AC-3+",
Codec::Lpcm => "LPCM",
Codec::Pgs => "PGS",
Codec::Unknown(_) => "Unknown",
}
}
fn from_coding_type(ct: u8) -> Self {
match ct {
0x24 => Codec::Hevc,
0x1B => Codec::H264,
0xEA => Codec::Vc1,
0x02 => Codec::Mpeg2,
0x83 => Codec::TrueHd,
0x86 => Codec::DtsHdMa,
0x85 => Codec::DtsHdHr,
0x82 => Codec::Dts,
0x81 => Codec::Ac3,
0x84 | 0xA1 => Codec::Ac3Plus,
0x80 => Codec::Lpcm,
0xA2 => Codec::DtsHdHr,
0x90 | 0x91 => Codec::Pgs,
ct => Codec::Unknown(ct),
}
}
}
impl HdrFormat {
pub fn name(&self) -> &'static str {
match self {
HdrFormat::Sdr => "SDR",
HdrFormat::Hdr10 => "HDR10",
HdrFormat::DolbyVision => "Dolby Vision",
}
}
}
impl ColorSpace {
pub fn name(&self) -> &'static str {
match self {
ColorSpace::Bt709 => "BT.709",
ColorSpace::Bt2020 => "BT.2020",
ColorSpace::Unknown => "",
}
}
}
impl Title {
/// Duration formatted as "Xh Ym"
pub fn duration_display(&self) -> String {
let hrs = (self.duration_secs / 3600.0) as u32;
let mins = ((self.duration_secs % 3600.0) / 60.0) as u32;
format!("{}h {:02}m", hrs, mins)
}
/// Size in GB
pub fn size_gb(&self) -> f64 {
self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0)
}
/// Total sectors across all extents
pub fn total_sectors(&self) -> u64 {
self.extents.iter().map(|e| e.sector_count as u64).sum()
}
}
impl Stream {
/// Human-readable one-line description.
pub fn display(&self) -> String {
match self.kind {
StreamKind::Video => {
let mut parts = vec![self.codec.name().to_string()];
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
parts.push(self.color_space.name().to_string());
}
if self.secondary { parts.push(format!("[{}]", self.label)); }
parts.join(" ")
}
StreamKind::Audio => {
let mut parts = vec![self.codec.name().to_string()];
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
if self.secondary { parts.push("[secondary]".to_string()); }
parts.join(" ")
}
StreamKind::Subtitle => {
let mut parts = vec![self.codec.name().to_string()];
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
parts.join(" ")
}
}
}
/// Kind as a display string
pub fn kind_name(&self) -> &'static str {
match self.kind {
StreamKind::Video => "Video",
StreamKind::Audio => "Audio",
StreamKind::Subtitle => "Subtitle",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
// Placeholder — the actual implementation will be wired in
// when the CLI's disc_info.rs parsing is migrated here.
// For now, the CLI does its own parsing.
impl Disc {
/// Disc capacity in GB
pub fn capacity_gb(&self) -> f64 {
self.capacity_sectors as f64 * 2048.0 / (1024.0 * 1024.0 * 1024.0)
}
}
+20
View File
@@ -128,4 +128,24 @@ impl DriveSession {
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)
}
/// 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
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}"),
}
}
}
+5
View File
@@ -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::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace, 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,
})
}
+2 -2
View File
@@ -36,7 +36,7 @@ pub trait ScsiTransport {
// ─── Linux: SG_IO ───────────────────────────────────────────────────────────
#[cfg(target_os = "linux")]
const SG_IO: libc::c_ulong = 0x2285;
const SG_IO: u32 = 0x2285;
#[cfg(target_os = "linux")]
const SG_DXFER_NONE: i32 = -1;
#[cfg(target_os = "linux")]
@@ -130,7 +130,7 @@ impl ScsiTransport for SgIoTransport {
hdr.timeout = timeout_ms;
let ret = unsafe {
libc::ioctl(self.fd, SG_IO, &mut hdr as *mut sg_io_hdr)
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
};
if ret < 0 {
+462
View File
@@ -0,0 +1,462 @@
//! UDF filesystem reader — read files from Blu-ray discs.
//!
//! Blu-ray discs use UDF 2.50 with metadata partitions.
//! The read sequence follows pointers through the disc structure:
//!
//! Sector 256 (AVDP)
//! → VDS (Partition Descriptor + Logical Volume Descriptor)
//! → Metadata Partition (virtual partition stored as a file)
//! → File Set Descriptor (FSD)
//! → Root Directory ICB
//! → Directory data (File Identifier Descriptors)
//! → BDMV/PLAYLIST/*.mpls, BDMV/CLIPINF/*.clpi
//!
//! Each step reads one or two sectors. No bulk reads needed.
//!
//! References:
//! ECMA-167 (UDF base)
//! UDF 2.50 (OSTA) — metadata partition extension
//! BD-ROM Part 3 — Blu-ray filesystem profile
use crate::error::{Error, Result};
use crate::drive::DriveSession;
/// A UDF filesystem parsed from disc.
#[derive(Debug)]
pub struct UdfFs {
/// Root directory with full tree
pub root: DirEntry,
/// Physical partition start (absolute sector)
partition_start: u32,
/// Metadata partition start (absolute sector)
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
metadata_start: u32,
}
/// A directory or file entry.
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
/// LBA within the metadata partition (add metadata_start for absolute)
pub meta_lba: u32,
/// File size in bytes (from ICB info_length)
pub size: u64,
/// Child entries (if directory)
pub entries: Vec<DirEntry>,
}
impl UdfFs {
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
/// Path matching is case-insensitive.
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 raw bytes.
/// Reads sector by sector from disc — no buffering.
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's ICB to get its data extent
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
// Read file data sector by sector
let sector_count = ((data_len as u64 + 2047) / 2048) as u32;
let mut data = vec![0u8; (sector_count as usize) * 2048];
let abs_start = self.meta_to_abs(data_lba);
for i in 0..sector_count {
let offset = (i as usize) * 2048;
read_sector(session, abs_start + i, &mut data[offset..offset + 2048])?;
}
data.truncate(entry.size as usize);
Ok(data)
}
/// Convert a metadata-partition-relative LBA to an absolute sector number.
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
self.metadata_start + meta_lba
}
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
/// and return its first allocation extent: (data_lba, data_length).
/// The data_lba is metadata-relative.
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
let mut icb = [0u8; 2048];
read_sector(session, self.meta_to_abs(meta_lba), &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
// Get allocation descriptor offset based on ICB type
let ad_offset = match tag {
// Extended File Entry (UDF 2.50, used by BD-ROM)
// Layout: ... L_EA at [208:212], L_AD at [212:216], alloc descs at 216 + L_EA
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
216 + l_ea
}
// Standard File Entry
// Layout: ... L_EA at [168:172], L_AD at [172:176], alloc descs at 176 + L_EA
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
176 + l_ea
}
_ => return Err(Error::DiscError {
detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba),
}),
};
if ad_offset + 8 > 2048 {
return Err(Error::DiscError { detail: "ICB alloc desc out of range".into() });
}
// Short Allocation Descriptor: extent_length(4) + extent_position(4)
// extent_length upper 2 bits = type (0=recorded, 1=allocated not recorded, 3=next extent)
let raw_len = u32::from_le_bytes([icb[ad_offset], icb[ad_offset + 1],
icb[ad_offset + 2], icb[ad_offset + 3]]);
let data_len = raw_len & 0x3FFFFFFF;
let data_lba = u32::from_le_bytes([icb[ad_offset + 4], icb[ad_offset + 5],
icb[ad_offset + 6], icb[ad_offset + 7]]);
Ok((data_lba, data_len))
}
}
/// Read the UDF filesystem from a Blu-ray disc.
///
/// Follows the UDF pointer chain:
/// 1. AVDP (sector 256) → VDS location
/// 2. VDS → Partition Descriptor (physical partition start)
/// → Logical Volume Descriptor (FSD location + partition maps)
/// 3. Metadata partition file → metadata content location
/// 4. FSD → root directory ICB
/// 5. Root directory → file tree
pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Step 1: Anchor Volume Descriptor Pointer at sector 256
// ECMA-167 §10.2 — always at sector 256
let mut avdp = [0u8; 2048];
read_sector(session, 256, &mut avdp)?;
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
if tag_id != 2 {
return Err(Error::DiscError {
detail: format!("AVDP: expected tag 2, got {} at sector 256", tag_id),
});
}
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
// (We use the VDS at sectors 32+, not the reserve copy at sector 32768+)
// Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically)
// Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6)
let mut partition_start: u32 = 0;
let mut num_partition_maps: u32 = 0;
let mut lvd_sector: Option<u32> = None;
for i in 32..64 {
let mut desc = [0u8; 2048];
read_sector(session, i, &mut desc)?;
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
match desc_tag {
// Partition Descriptor — tells us where the physical partition starts
5 => {
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
}
// Logical Volume Descriptor — contains FSD location and partition maps
6 => {
num_partition_maps = u32::from_le_bytes([desc[268], desc[269], desc[270], desc[271]]);
lvd_sector = Some(i);
}
// Terminating Descriptor — end of VDS
8 => break,
_ => continue,
}
}
if partition_start == 0 {
return Err(Error::DiscError { detail: "UDF: no Partition Descriptor found".into() });
}
// Step 3: Parse partition maps from LVD to find metadata partition
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
// The metadata file is stored at lba=0 of the physical partition
let metadata_start = if num_partition_maps >= 2 {
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscError {
detail: "UDF: no LVD found".into(),
})?;
// Read LVD to check partition map type
let mut lvd = [0u8; 2048];
read_sector(session, lvd_sec, &mut lvd)?;
// Parse partition maps starting at offset 440
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
let pm1_type = lvd[440]; // First map type
let pm1_len = lvd[441] as usize;
if pm1_len > 0 && 440 + pm1_len < 2048 {
let pm2_type = lvd[440 + pm1_len]; // Second map type
if pm2_type == 2 {
// Type 2 = metadata partition
// The metadata file ICB is at physical partition lba 0
// Read it to find where the metadata content starts
let meta_file_lba = partition_start; // lba 0 of partition
let mut meta_icb = [0u8; 2048];
read_sector(session, meta_file_lba, &mut meta_icb)?;
let meta_tag = u16::from_le_bytes([meta_icb[0], meta_icb[1]]);
if meta_tag == 266 {
// Extended File Entry — get allocation extent
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
meta_icb[210], meta_icb[211]]) as usize;
let ad_off = 216 + l_ea;
let _ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
// Metadata content starts at partition_start + ad_pos
partition_start + ad_pos
} else {
// Fallback: no metadata partition, use physical partition directly
partition_start
}
} else {
partition_start
}
} else {
partition_start
}
} else {
// Single partition map — no metadata partition (older UDF)
partition_start
};
// Step 4: Read File Set Descriptor from metadata partition
// FSD is at metadata-relative lba 0 (first sector of metadata content)
let mut fsd = [0u8; 2048];
read_sector(session, metadata_start, &mut fsd)?;
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
if fsd_tag != 256 {
return Err(Error::DiscError {
detail: format!("FSD: expected tag 256, got {} at sector {}", fsd_tag, metadata_start),
});
}
// Root Directory ICB: long_ad at FSD offset 400
// long_ad = extent_length(4) + extent_location: lba(4) + part_ref(2) + impl_use(6)
let root_lba = u32::from_le_bytes([fsd[404], fsd[405], fsd[406], fsd[407]]);
// Step 5: Read root directory and build file tree
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
Ok(UdfFs {
root,
partition_start,
metadata_start,
})
}
/// Read a UDF directory and its children (up to max_depth levels).
///
/// Each directory is an ICB (Extended File Entry) pointing to directory data
/// containing File Identifier Descriptors (FIDs). Each FID names a file/subdir
/// and points to its ICB.
fn read_directory(
session: &mut DriveSession,
part_start: u32,
meta_start: u32,
meta_lba: u32,
name: &str,
depth: u32,
) -> Result<DirEntry> {
// Read ICB for this directory
let mut icb = [0u8; 2048];
read_sector(session, meta_start + meta_lba, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
// Get allocation extent: where the directory data lives
let (ad_len, ad_pos) = match tag {
266 => {
let l_ea = u32::from_le_bytes([icb[208], icb[209], icb[210], icb[211]]) as usize;
let ad_off = 216 + l_ea;
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
icb[ad_off + 6], icb[ad_off + 7]]);
(len, pos)
}
261 => {
let l_ea = u32::from_le_bytes([icb[168], icb[169], icb[170], icb[171]]) as usize;
let ad_off = 176 + l_ea;
let len = u32::from_le_bytes([icb[ad_off], icb[ad_off + 1],
icb[ad_off + 2], icb[ad_off + 3]]) & 0x3FFFFFFF;
let pos = u32::from_le_bytes([icb[ad_off + 4], icb[ad_off + 5],
icb[ad_off + 6], icb[ad_off + 7]]);
(len, pos)
}
_ => {
return Ok(DirEntry {
name: name.to_string(), is_dir: true, meta_lba, size: 0, entries: Vec::new(),
});
}
};
// Read directory data
let dir_abs = meta_start + ad_pos;
let sector_count = ((ad_len + 2047) / 2048).min(64);
let mut dir_data = vec![0u8; sector_count as usize * 2048];
for i in 0..sector_count {
read_sector(session, dir_abs + i,
&mut dir_data[(i as usize) * 2048..(i as usize + 1) * 2048])?;
}
// Parse File Identifier Descriptors
let mut entries = Vec::new();
let mut pos = 0;
while pos + 38 < dir_data.len().min(ad_len as usize) {
let fid_tag = u16::from_le_bytes([dir_data[pos], dir_data[pos + 1]]);
if fid_tag != 257 {
break;
}
let file_chars = dir_data[pos + 18];
let l_fi = dir_data[pos + 19] as usize;
// FID ICB is a long_ad starting at offset 20:
// [20:24] = extent_length
// [24:28] = extent_location (LBA within metadata partition)
// [28:30] = partition_reference_number
// [30:36] = implementation_use
let icb_lba = u32::from_le_bytes([dir_data[pos + 24], dir_data[pos + 25],
dir_data[pos + 26], dir_data[pos + 27]]);
let l_iu = u16::from_le_bytes([dir_data[pos + 36], dir_data[pos + 37]]) as usize;
let is_dir = (file_chars & 0x02) != 0;
let is_parent = (file_chars & 0x08) != 0;
if !is_parent && l_fi > 0 {
let name_start = pos + 38 + l_iu;
let entry_name = parse_udf_name(&dir_data[name_start..name_start + l_fi]);
if !entry_name.is_empty() {
// Read the ICB to get file size
let file_size = read_file_size(session, meta_start, icb_lba).unwrap_or(0);
if is_dir && depth < 3 {
// Recurse into subdirectory (max 3 levels: BDMV/PLAYLIST/*.mpls)
let subdir = read_directory(session, part_start, meta_start, icb_lba, &entry_name, depth + 1)?;
entries.push(subdir);
} else {
entries.push(DirEntry {
name: entry_name,
is_dir,
meta_lba: icb_lba,
size: file_size,
entries: Vec::new(),
});
}
}
}
// Advance to next FID (4-byte aligned)
let fid_len = ((38 + l_iu + l_fi + 3) & !3) as usize;
pos += fid_len;
}
Ok(DirEntry {
name: name.to_string(),
is_dir: true,
meta_lba,
size: ad_len as u64,
entries,
})
}
/// Read file size (info_length) from an Extended File Entry ICB.
fn read_file_size(session: &mut DriveSession, meta_start: u32, meta_lba: u32) -> Result<u64> {
let mut icb = [0u8; 2048];
read_sector(session, meta_start + meta_lba, &mut icb)?;
let tag = u16::from_le_bytes([icb[0], icb[1]]);
match tag {
// Both File Entry (261) and Extended File Entry (266) have
// info_length as a u64 at offset 56
261 | 266 => {
Ok(u64::from_le_bytes([icb[56], icb[57], icb[58], icb[59],
icb[60], icb[61], icb[62], icb[63]]))
}
_ => Ok(0),
}
}
/// Parse a UDF filename from raw bytes.
///
/// UDF uses a compression ID as the first byte:
/// 8 = 8-bit characters (ASCII)
/// 16 = 16-bit big-endian Unicode (UTF-16BE)
fn parse_udf_name(data: &[u8]) -> String {
if data.is_empty() {
return String::new();
}
match data[0] {
8 => {
// 8-bit ASCII
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.
/// Uses standard READ(10) — no unlock required.
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {
session.read_disc(lba, 1, buf)?;
Ok(())
}