mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e34b41967d | ||
|
|
c063dbd76e | ||
|
|
d1f8db93d6 | ||
|
|
926939f24b | ||
|
|
48d70c092c | ||
|
|
c14b319212 | ||
|
|
55b22bfb9d | ||
|
|
48b212c9d2 | ||
|
|
074f21ba58 | ||
|
|
9241d767ee | ||
|
|
65996ade59 | ||
|
|
09f5bb7816 | ||
|
|
12a5985a58 | ||
|
|
d23632931b | ||
|
|
92e7692779 | ||
|
|
0c11623666 | ||
|
|
b454d100c6 | ||
|
|
097473d371 | ||
|
|
5e3157b18d | ||
|
|
d9c5f7a0d2 | ||
|
|
6456e24bb5 | ||
|
|
0b19154bd3 | ||
|
|
ecc6cd9f6b | ||
|
|
71a21b5c6f |
@@ -3,3 +3,4 @@ Cargo.lock
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.cargo/
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 (2026-04-10)
|
||||
|
||||
### API improvements
|
||||
|
||||
- **`open()` works on all drives** — no profile match required. Unknown drives can scan, read BD/DVD at OEM speed. `init()` is optional and adds features (riplock removal, UHD reads, speed control).
|
||||
- **`has_profile()`** — check if unlock parameters are available for this drive
|
||||
- **`find_drives()`** — returns all optical drives, not just profile-matched ones
|
||||
- **`raw_gc_010c`** on `DriveId` — raw GET_CONFIG 010C response bytes for profile sharing
|
||||
|
||||
### AACS 2.0
|
||||
|
||||
- **SCSI handshake wired end-to-end** — ECDH key agreement, real Volume ID from drive, read data key for bus decryption
|
||||
- **Bus decryption active** — UHD discs with bus encryption now decrypted transparently
|
||||
- **VUK derivation from Media Key + VID** — works for discs not in KEYDB (processing key + device key paths)
|
||||
|
||||
### MKV muxer
|
||||
|
||||
- **15 new files** — EBML writer, TS demuxer, stream assembly pipeline
|
||||
- **Codec parsers** — H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1
|
||||
- **`MkvStream`** — builder pattern, wraps any `impl Write`, configurable lookahead buffer
|
||||
|
||||
### Cleanup
|
||||
|
||||
- Removed orphaned `jar.rs` (342 lines) — replaced by `labels/` module
|
||||
- Error refactor: 40+ sites converted from English strings to typed error codes
|
||||
|
||||
## 0.5.0 (2026-04-09)
|
||||
|
||||
### Read pipeline — 5x speed improvement
|
||||
|
||||
- **Kernel transfer limit detection**: auto-detect `max_hw_sectors_kb` via sysfs, resolve sg→block device. Previously hardcoded to 510 sectors (1MB) which exceeded the 120KB kernel limit, causing all reads to error and fall back to 6KB reads at 4.8 MB/s. Now auto-tunes to 48 sectors (96KB) or whatever the device supports.
|
||||
- **Result: 12.5 MB/s sustained, 23 MB/s peak** (was 4.8 MB/s)
|
||||
|
||||
### LibreDrive — full init pipeline
|
||||
|
||||
- **All 10 ARM handlers translated**: unlock, firmware upload (A: WRITE_BUFFER, B: MODE SELECT), calibrate (256 zones), register reads, status, probe, set_read_speed, keepalive, timing
|
||||
- **Cold boot firmware upload**: WRITE_BUFFER 1888B (A variant) or MODE SELECT 2496B (B variant) proven on hardware
|
||||
- **Speed calibration**: 256+ disc surface probes, 64-entry speed table, triple SET_CD_SPEED
|
||||
- **Platform trait locked down**: `pub(crate)`, 3 methods only (init, set_read_speed, is_ready)
|
||||
- **Init guard**: prevents double-init, signature mismatch aborts early
|
||||
|
||||
### MPLS parser fixes
|
||||
|
||||
- **PGS in audio slots**: subtitle language read at correct offset (was truncated: "ng " → "eng")
|
||||
- **Secondary PG entries**: n_pip_pg loop added for correct STN position tracking
|
||||
- **Secondary stream types**: stream_type 5 (sec audio), 6 (sec video), 7 (DV EL) attribute parsing
|
||||
- **Empty stream filter**: coding_type 0x00 entries (padding) no longer appear as "Unknown(0)"
|
||||
|
||||
### Profiles
|
||||
|
||||
- **206 profiles with full per-drive data**: ld_microcode (base64), all CDBs, speed tables, signatures
|
||||
- **Automated pipeline**: `sdf_unpack --profiles` → profiles.json (no manual merging)
|
||||
|
||||
## 0.4.0 (2026-04-07)
|
||||
|
||||
### Labels — complete rewrite
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
|
||||
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
|
||||
|
||||
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
|
||||
@@ -16,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.4"
|
||||
libfreemkv = "0.6"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -27,6 +29,9 @@ use std::path::Path;
|
||||
|
||||
// Open drive — profiles are bundled, auto-identified
|
||||
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
|
||||
session.wait_ready()?; // wait for disc
|
||||
session.init()?; // unlock + firmware upload
|
||||
session.probe_disc()?; // probe disc surface for optimal speeds
|
||||
|
||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
@@ -44,22 +49,23 @@ while let Some(unit) = reader.read_unit()? {
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock, eject
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0, 2.0 in progress)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — sector reads with automatic decryption
|
||||
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
DriveSession — open, identify, unlock, read sectors
|
||||
DriveSession — open any drive, identify, init (optional), read sectors
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── Platform — MediaTek (supported), Renesas (planned)
|
||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
||||
|
||||
Disc — scan titles, streams, AACS state
|
||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
@@ -97,7 +103,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
||||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let target = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# libfreemkv API Design
|
||||
|
||||
## Principles
|
||||
|
||||
1. Lib provides building blocks. App composes them.
|
||||
2. No English text in lib. Error codes only. App handles i18n.
|
||||
3. No display logic in lib. App decides what to show.
|
||||
4. Streams are the pipeline. Each stage wraps the next.
|
||||
5. Lib fires events. App listens.
|
||||
|
||||
## Core API
|
||||
|
||||
```rust
|
||||
// Open drive — explicit steps, app prints between them
|
||||
let mut session = DriveSession::open(path)?;
|
||||
session.wait_ready()?;
|
||||
session.init()?;
|
||||
session.probe_disc()?;
|
||||
|
||||
// Scan disc
|
||||
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
|
||||
|
||||
// Browse
|
||||
disc.titles // Vec<Title>
|
||||
disc.format // BD / UHD / DVD
|
||||
disc.capacity_gb()
|
||||
|
||||
// Rip with events
|
||||
disc.rip(&mut session, 0, output, |event| {
|
||||
match event.kind {
|
||||
EventKind::BytesRead { bytes, total } => ...,
|
||||
EventKind::ReadError { sector, error } => ...,
|
||||
EventKind::Retry { attempt } => ...,
|
||||
EventKind::SpeedChange { speed_kbs } => ...,
|
||||
EventKind::Complete { bytes, errors } => ...,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Rip without events
|
||||
disc.rip(&mut session, 0, output, event::ignore)?;
|
||||
```
|
||||
|
||||
## Stream Chains
|
||||
|
||||
Each stream wraps the next. Builder pattern, no `.build()`.
|
||||
|
||||
### Raw m2ts
|
||||
```rust
|
||||
disc.rip(&mut session, 0, File::create("movie.m2ts")?, event::ignore)?;
|
||||
```
|
||||
|
||||
### MKV
|
||||
```rust
|
||||
let output = MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### MKV with progress (CLI)
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(10 * 1024 * 1024),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
### Future: transcode
|
||||
```rust
|
||||
let output = ProgressStream::new(
|
||||
TranscodeStream::new(
|
||||
MkvStream::new(File::create("movie.mkv")?)
|
||||
.title(&disc.titles[0])
|
||||
.max_buffer(50 * 1024 * 1024),
|
||||
)
|
||||
.codec(H265)
|
||||
.quality(22),
|
||||
total_bytes,
|
||||
|pct, speed| eprint!("\r {}% {:.1} MB/s", pct, speed),
|
||||
);
|
||||
|
||||
disc.rip(&mut session, 0, output, |e| { ... })?;
|
||||
```
|
||||
|
||||
## Events
|
||||
|
||||
Lib fires events during operations. App provides a callback. No display, no text.
|
||||
|
||||
```rust
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
}
|
||||
```
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI prints a line. Server logs to file.
|
||||
|
||||
## Error Codes
|
||||
|
||||
Lib errors are codes, not messages. Like HTTP status codes.
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
// Drive
|
||||
DriveNotFound,
|
||||
DriveOpenFailed,
|
||||
DriveNotReady,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
NoProfile,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertVerifyFailed,
|
||||
AacsAgidAllocFailed,
|
||||
AacsHandshakeFailed,
|
||||
AacsVidMacFailed,
|
||||
|
||||
// Disc
|
||||
DiscReadError { sector: u64 },
|
||||
MplsParseError,
|
||||
ClpiParseError,
|
||||
UdfFileNotFound { path: String },
|
||||
|
||||
// Mux
|
||||
LookaheadOverflow,
|
||||
MuxWriteError,
|
||||
|
||||
// SCSI
|
||||
ScsiError { sense: u8 },
|
||||
}
|
||||
```
|
||||
|
||||
App maps codes to localized strings. Lib never contains display text.
|
||||
|
||||
## Streams the Lib Provides
|
||||
|
||||
| Stream | Purpose |
|
||||
|--------|---------|
|
||||
| MkvStream | BD-TS → MKV (demux + mux) |
|
||||
|
||||
CLI provides:
|
||||
| Stream | Purpose |
|
||||
|--------|---------|
|
||||
| ProgressStream | Byte counting + progress callback |
|
||||
| Future: TranscodeStream | Re-encode video |
|
||||
|
||||
Any `impl Write` works as a stream. Third-party apps create their own.
|
||||
|
||||
## MkvStream Internals
|
||||
|
||||
LookaheadBuffer (default 5MB, configurable):
|
||||
1. Phase 1: buffer incoming data, scan for codec setup (SPS/PPS)
|
||||
2. Found it? Write MKV header, flush buffer, switch to streaming
|
||||
3. Buffer full? Error — app handles it
|
||||
4. Phase 2: parse TS → frames → MKV clusters, direct to output
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive.rs DriveSession (open, init, read)
|
||||
├── disc.rs Disc (scan, rip, titles)
|
||||
├── scsi/ SCSI transport (Linux, macOS)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── aacs/ AACS decryption
|
||||
├── udf.rs UDF filesystem parser
|
||||
├── mpls.rs Playlist parser
|
||||
├── clpi.rs Clip info parser
|
||||
├── mux/
|
||||
│ ├── stream.rs MkvStream (builder pattern, impl Write)
|
||||
│ ├── lookahead.rs LookaheadBuffer (generic, reusable)
|
||||
│ ├── ts.rs BD-TS demuxer
|
||||
│ ├── ebml.rs EBML primitives
|
||||
│ ├── mkv.rs MKV muxer
|
||||
│ └── codec/ Frame parsers (H.264, HEVC, VC-1, AC3, DTS, TrueHD, PGS)
|
||||
└── ...
|
||||
|
||||
freemkv/src/
|
||||
├── main.rs CLI entry, command routing
|
||||
├── rip.rs Rip command (streams + progress)
|
||||
├── remux.rs Remux command (m2ts → MKV, no drive)
|
||||
├── info.rs Drive info display
|
||||
└── disc_info.rs Disc info display
|
||||
```
|
||||
@@ -0,0 +1,240 @@
|
||||
# MKV Native Muxer — Architecture
|
||||
|
||||
## Goal
|
||||
|
||||
Replace raw m2ts output with in-pipeline MKV muxing.
|
||||
Disc → TS demux → MKV mux → .mkv file. One pass, no temp files.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
ContentReader::read_batch() returns &[u8] of raw BD transport stream
|
||||
↓
|
||||
TsDemuxer::feed(batch) parses 192-byte BD-TS packets, extracts PES
|
||||
↓
|
||||
PES reassembly per PID builds complete PES packets with PTS/DTS
|
||||
↓
|
||||
ElementaryStreamParser per track finds frame boundaries, extracts codec headers
|
||||
↓
|
||||
MkvMuxer::write_frame(track, pts, data) writes EBML clusters + blocks
|
||||
↓
|
||||
.mkv file on disk
|
||||
```
|
||||
|
||||
## Current Integration Point
|
||||
|
||||
```rust
|
||||
// rip.rs line ~287
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
writer.write_all(batch)?; // ← replace with muxer.feed(batch)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Becomes:
|
||||
```rust
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
muxer.feed(batch)?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. BD Transport Stream Demuxer (`ts.rs`)
|
||||
|
||||
BD uses 192-byte packets (not standard 188):
|
||||
```
|
||||
[0-3] TP_extra_header: 2-bit copy_permission + 30-bit arrival_time_stamp
|
||||
[4] Sync byte: 0x47
|
||||
[5] TEI + PUSI + priority + PID[12:8]
|
||||
[6] PID[7:0]
|
||||
[7] Scrambling + adaptation + continuity_counter
|
||||
[8..] Adaptation field (if present) + payload
|
||||
```
|
||||
|
||||
API:
|
||||
```rust
|
||||
pub struct TsDemuxer {
|
||||
pes_assemblers: HashMap<u16, PesAssembler>, // PID → assembler
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
pub fn new(pids: &[u16]) -> Self;
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket>;
|
||||
}
|
||||
|
||||
pub struct PesPacket {
|
||||
pub pid: u16,
|
||||
pub pts: Option<i64>, // 90kHz ticks
|
||||
pub dts: Option<i64>, // 90kHz ticks
|
||||
pub data: Vec<u8>, // elementary stream data
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Elementary Stream Parsers (`codec/`)
|
||||
|
||||
Each codec parser finds frame boundaries and extracts initialization data.
|
||||
|
||||
**H.264 (`codec/h264.rs`):**
|
||||
- Parse NAL units (start code 00 00 01 or 00 00 00 01)
|
||||
- Extract SPS + PPS for codecPrivate
|
||||
- Frame boundary = Access Unit Delimiter (NAL type 9) or SPS
|
||||
|
||||
**HEVC (`codec/hevc.rs`):**
|
||||
- Parse NAL units
|
||||
- Extract VPS + SPS + PPS for codecPrivate
|
||||
- Frame boundary = VCL NAL with first_slice_segment_in_pic_flag
|
||||
|
||||
**AC3/EAC3 (`codec/ac3.rs`):**
|
||||
- Syncword 0x0B77
|
||||
- Parse frame size from header
|
||||
- No codecPrivate needed (or minimal)
|
||||
|
||||
**DTS (`codec/dts.rs`):**
|
||||
- Syncword 0x7FFE8001
|
||||
- Parse frame size
|
||||
- No codecPrivate needed
|
||||
|
||||
**TrueHD (`codec/truehd.rs`):**
|
||||
- Major sync: 0xF8726FBA
|
||||
- Access unit = major sync + minor syncs
|
||||
- AC3 core embedded in first substream
|
||||
|
||||
**LPCM (`codec/lpcm.rs`):**
|
||||
- Fixed frame sizes based on sample rate + channels
|
||||
- Header describes format
|
||||
|
||||
**PGS (`codec/pgs.rs`):**
|
||||
- Segment types: PCS, WDS, PDS, ODS, END
|
||||
- Each segment is a complete unit
|
||||
- No codecPrivate needed
|
||||
|
||||
### 3. MKV/EBML Muxer (`mkv.rs`)
|
||||
|
||||
Matroska uses EBML (Extensible Binary Meta Language).
|
||||
|
||||
**EBML primitives:**
|
||||
- Variable-length element ID (1-4 bytes)
|
||||
- Variable-length size (1-8 bytes)
|
||||
- Data: uint, int, float, string, UTF-8, binary, date
|
||||
|
||||
**MKV structure:**
|
||||
```
|
||||
EBML Header
|
||||
Segment
|
||||
├── SeekHead (index of top-level elements)
|
||||
├── Info (title, duration, muxing app)
|
||||
├── Tracks (one entry per stream)
|
||||
│ ├── TrackEntry (video)
|
||||
│ │ ├── CodecID: "V_MPEG4/ISO/AVC" or "V_MPEGH/ISO/HEVC"
|
||||
│ │ ├── CodecPrivate: SPS+PPS (H.264) or VPS+SPS+PPS (HEVC)
|
||||
│ │ └── Video: PixelWidth, PixelHeight, DisplayWidth, DisplayHeight
|
||||
│ ├── TrackEntry (audio)
|
||||
│ │ ├── CodecID: "A_AC3" or "A_TRUEHD" or "A_DTS"
|
||||
│ │ └── Audio: SamplingFrequency, Channels, BitDepth
|
||||
│ └── TrackEntry (subtitle)
|
||||
│ └── CodecID: "S_HDMV/PGS"
|
||||
├── Chapters (optional, from MPLS chapter marks)
|
||||
├── Cluster (every ~5 seconds)
|
||||
│ ├── Timestamp (cluster base time)
|
||||
│ ├── SimpleBlock (track, relative_ts, data)
|
||||
│ ├── SimpleBlock ...
|
||||
│ └── ...
|
||||
├── Cluster ...
|
||||
├── Cues (seek index, written at end)
|
||||
└── Tags (metadata)
|
||||
```
|
||||
|
||||
**API:**
|
||||
```rust
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
tracks: Vec<MkvTrack>,
|
||||
cluster_start: Option<i64>,
|
||||
cue_points: Vec<CuePoint>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
pub fn new(writer: W, tracks: &[MkvTrack]) -> Result<Self>;
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>; // writes Cues + fixes SeekHead
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Pipeline Glue (`mux.rs`)
|
||||
|
||||
Ties everything together:
|
||||
|
||||
```rust
|
||||
pub struct MuxPipeline<W: Write + Seek> {
|
||||
demuxer: TsDemuxer,
|
||||
parsers: HashMap<u16, Box<dyn CodecParser>>,
|
||||
muxer: MkvMuxer<W>,
|
||||
pid_to_track: HashMap<u16, usize>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MuxPipeline<W> {
|
||||
pub fn new(writer: W, streams: &[Stream]) -> Result<Self>;
|
||||
pub fn feed(&mut self, ts_data: &[u8]) -> Result<()>;
|
||||
pub fn finish(self) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
libfreemkv/src/
|
||||
├── mux/
|
||||
│ ├── mod.rs MuxPipeline (glue)
|
||||
│ ├── ts.rs BD-TS demuxer (192-byte packets)
|
||||
│ ├── ebml.rs EBML primitives (write variable-length ints)
|
||||
│ ├── mkv.rs MKV muxer (Segment, Tracks, Clusters)
|
||||
│ └── codec/
|
||||
│ ├── mod.rs CodecParser trait
|
||||
│ ├── h264.rs H.264 NAL parser
|
||||
│ ├── hevc.rs HEVC NAL parser
|
||||
│ ├── ac3.rs AC3/EAC3 frame parser
|
||||
│ ├── dts.rs DTS frame parser
|
||||
│ ├── truehd.rs TrueHD/Atmos parser
|
||||
│ ├── lpcm.rs LPCM frame parser
|
||||
│ └── pgs.rs PGS subtitle parser
|
||||
```
|
||||
|
||||
## MKV Codec IDs
|
||||
|
||||
| Our Codec | MKV CodecID | codecPrivate |
|
||||
|-----------|------------|--------------|
|
||||
| H264 | V_MPEG4/ISO/AVC | AVCDecoderConfigurationRecord (SPS+PPS) |
|
||||
| Hevc | V_MPEGH/ISO/HEVC | HEVCDecoderConfigurationRecord (VPS+SPS+PPS) |
|
||||
| Vc1 | V_MS/VFW/FOURCC | BITMAPINFOHEADER |
|
||||
| Mpeg2 | V_MPEG2 | sequence_header |
|
||||
| Ac3 | A_AC3 | none |
|
||||
| Ac3Plus | A_EAC3 | none |
|
||||
| TrueHd | A_TRUEHD | none |
|
||||
| DtsHdMa | A_DTS | none (core + extension) |
|
||||
| Dts | A_DTS | none |
|
||||
| Lpcm | A_PCM/INT/BIG | none |
|
||||
| Pgs | S_HDMV/PGS | none |
|
||||
|
||||
## Timestamps
|
||||
|
||||
BD uses 90kHz PTS/DTS. MKV uses nanoseconds.
|
||||
Conversion: `ns = pts * 1_000_000_000 / 90_000` = `pts * 100_000 / 9`
|
||||
|
||||
MKV TimestampScale default = 1,000,000 (1ms precision).
|
||||
For BD content, 1ms is sufficient.
|
||||
|
||||
## Build Order
|
||||
|
||||
1. `ebml.rs` — EBML write primitives (smallest, no dependencies)
|
||||
2. `ts.rs` — BD-TS demuxer (parse 192-byte packets, PES assembly)
|
||||
3. `codec/ac3.rs` — simplest codec parser (fixed syncword)
|
||||
4. `mkv.rs` — MKV muxer (header, tracks, clusters, blocks)
|
||||
5. `mux.rs` — pipeline glue
|
||||
6. Test with AC3-only stream (simplest case)
|
||||
7. `codec/h264.rs` — video parser (NAL units, SPS/PPS)
|
||||
8. Full BD rip test (video + audio + subs)
|
||||
9. Remaining codecs (HEVC, DTS, TrueHD, PGS, LPCM, VC-1)
|
||||
+2067
-5358
File diff suppressed because it is too large
Load Diff
+26
-26
@@ -203,7 +203,9 @@ fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
(p - (&p1.x - &p2.x) % p) % p
|
||||
};
|
||||
|
||||
let dx_inv = mod_inv(&dx, p).unwrap();
|
||||
// Safety: mod_inv only returns None if dx == 0 (points identical),
|
||||
// which is prevented by the caller using ec_double for that case.
|
||||
let dx_inv = mod_inv(&dx, p).expect("ec_add: dx has no inverse");
|
||||
let lam = (&dy * &dx_inv) % p;
|
||||
|
||||
// x3 = λ² - x1 - x2 mod p
|
||||
@@ -247,7 +249,9 @@ fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
|
||||
|
||||
let numerator = (&three * &pt.x * &pt.x + a) % p;
|
||||
let denominator = (&two * &pt.y) % p;
|
||||
let denom_inv = mod_inv(&denominator, p).unwrap();
|
||||
// Safety: mod_inv only returns None if 2*y == 0 (point at infinity),
|
||||
// which shouldn't occur with valid curve points.
|
||||
let denom_inv = mod_inv(&denominator, p).expect("ec_double: denominator has no inverse");
|
||||
let lam = (&numerator * &denom_inv) % p;
|
||||
|
||||
// x3 = λ² - 2x mod p
|
||||
@@ -715,7 +719,7 @@ pub fn aacs_authenticate(
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 92 {
|
||||
return Err(Error::AacsError { detail: "host certificate too short".into() });
|
||||
return Err(Error::AacsCertShort);
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
@@ -727,7 +731,7 @@ pub fn aacs_authenticate(
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|e| Error::AacsError { detail: format!("failed to allocate AGID: {}", e) })?;
|
||||
.map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce and ephemeral key pair
|
||||
@@ -744,12 +748,12 @@ pub fn aacs_authenticate(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 116);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?;
|
||||
.map_err(|_| Error::AacsCertRejected)?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce (REPORT KEY format 0x01)
|
||||
let cdb = cdb_report_key(agid, 0x01, 116);
|
||||
let response = scsi_read(session, &cdb, 116)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive certificate".into() })?;
|
||||
.map_err(|_| Error::AacsCertRead)?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
let mut drive_cert = [0u8; 92];
|
||||
@@ -760,7 +764,7 @@ pub fn aacs_authenticate(
|
||||
if drive_cert[0] == 0x01 {
|
||||
// AACS 1.0 certificate
|
||||
if !verify_cert(&drive_cert) {
|
||||
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
|
||||
return Err(Error::AacsCertVerify);
|
||||
}
|
||||
} else if drive_cert[0] == 0x11 {
|
||||
// AACS 2.0 certificate — verify with P-256 LA key
|
||||
@@ -771,7 +775,7 @@ pub fn aacs_authenticate(
|
||||
// Step 6: Read drive key point + signature (REPORT KEY format 0x02)
|
||||
let cdb = cdb_report_key(agid, 0x02, 84);
|
||||
let response = scsi_read(session, &cdb, 84)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read drive key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRead)?;
|
||||
|
||||
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
|
||||
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
|
||||
@@ -790,7 +794,7 @@ pub fn aacs_authenticate(
|
||||
sig_s.copy_from_slice(&drive_key_sig[20..40]);
|
||||
|
||||
if !ecdsa_verify(&drive_pub_x, &drive_pub_y, &sig_r, &sig_s, &verify_data) {
|
||||
return Err(Error::AacsError { detail: "drive key signature verification failed".into() });
|
||||
return Err(Error::AacsKeyVerify);
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
|
||||
@@ -811,7 +815,7 @@ pub fn aacs_authenticate(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 84);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRejected)?;
|
||||
|
||||
// Step 9: Compute bus key via ECDH
|
||||
let mut dkp_x = [0u8; 20];
|
||||
@@ -851,12 +855,8 @@ pub fn aacs2_authenticate(
|
||||
}
|
||||
|
||||
// AACS 2.0 native P-256 handshake
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or_else(|| Error::AacsError {
|
||||
detail: "AACS 2.0 host credentials required but not available".into(),
|
||||
})?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or_else(|| Error::AacsError {
|
||||
detail: "AACS 2.0 host certificate required but not available".into(),
|
||||
})?;
|
||||
let host_priv_v2 = host_priv_key_v2.ok_or(Error::AacsCertShort)?;
|
||||
let host_cert_v2 = host_cert_v2.ok_or(Error::AacsCertShort)?;
|
||||
|
||||
aacs2_authenticate_p256(session, host_priv_v2, host_cert_v2)
|
||||
}
|
||||
@@ -869,7 +869,7 @@ fn aacs2_authenticate_p256(
|
||||
host_cert: &[u8],
|
||||
) -> Result<AacsAuth> {
|
||||
if host_cert.len() < 132 {
|
||||
return Err(Error::AacsError { detail: "AACS 2.0 host cert too short".into() });
|
||||
return Err(Error::AacsCertShort);
|
||||
}
|
||||
|
||||
// Step 1: Invalidate all AGIDs
|
||||
@@ -881,7 +881,7 @@ fn aacs2_authenticate_p256(
|
||||
// Step 2: Allocate AGID
|
||||
let cdb = cdb_report_key(0, 0x00, 8);
|
||||
let response = scsi_read(session, &cdb, 8)
|
||||
.map_err(|_| Error::AacsError { detail: "AGID allocation failed".into() })?;
|
||||
.map_err(|_| Error::AacsAgidAlloc)?;
|
||||
let agid = (response[7] >> 6) & 0x03;
|
||||
|
||||
// Step 3: Generate host nonce + P-256 ephemeral key pair
|
||||
@@ -899,13 +899,13 @@ fn aacs2_authenticate_p256(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x01, 156);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host cert".into() })?;
|
||||
.map_err(|_| Error::AacsCertRejected)?;
|
||||
|
||||
// Step 5: Read drive certificate + nonce
|
||||
// AACS 2.0 drive cert is also 132 bytes
|
||||
let cdb = cdb_report_key(agid, 0x01, 156);
|
||||
let response = scsi_read(session, &cdb, 156)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive cert".into() })?;
|
||||
.map_err(|_| Error::AacsCertRead)?;
|
||||
|
||||
let mut drive_nonce = [0u8; 20];
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
@@ -919,7 +919,7 @@ fn aacs2_authenticate_p256(
|
||||
// Step 6: Read drive key point + signature (P-256: 64+64 = 128 bytes)
|
||||
let cdb = cdb_report_key(agid, 0x02, 132);
|
||||
let response = scsi_read(session, &cdb, 132)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read AACS 2.0 drive key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRead)?;
|
||||
|
||||
let drive_key_x = &response[4..36];
|
||||
let drive_key_y = &response[36..68];
|
||||
@@ -934,7 +934,7 @@ fn aacs2_authenticate_p256(
|
||||
verify_data.extend_from_slice(drive_key_y);
|
||||
|
||||
if !ecdsa_verify_p256(&drive_pub_x, &drive_pub_y, drive_sig_r, drive_sig_s, &verify_data) {
|
||||
return Err(Error::AacsError { detail: "AACS 2.0 drive key verification failed".into() });
|
||||
return Err(Error::AacsKeyVerify);
|
||||
}
|
||||
|
||||
// Step 7: Sign host key point
|
||||
@@ -955,7 +955,7 @@ fn aacs2_authenticate_p256(
|
||||
|
||||
let cdb = cdb_send_key(agid, 0x02, 132);
|
||||
scsi_write(session, &cdb, &send_buf)
|
||||
.map_err(|_| Error::AacsError { detail: "drive rejected AACS 2.0 host key".into() })?;
|
||||
.map_err(|_| Error::AacsKeyRejected)?;
|
||||
|
||||
// Step 9: Compute bus key via P-256 ECDH
|
||||
let bus_key = compute_bus_key_p256(&host_eph_key, drive_key_x, drive_key_y);
|
||||
@@ -978,7 +978,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
// REPORT DISC STRUCTURE format 0x80
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x80, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read Volume ID".into() })?;
|
||||
.map_err(|_| Error::AacsVidRead)?;
|
||||
|
||||
let mut vid = [0u8; 16];
|
||||
let mut mac = [0u8; 16];
|
||||
@@ -988,7 +988,7 @@ pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
// Verify MAC: AES-CMAC(VID, bus_key) should equal mac
|
||||
let calc_mac = aes_cmac_16(&vid, &auth.bus_key);
|
||||
if calc_mac != mac {
|
||||
return Err(Error::AacsError { detail: "VID MAC verification failed".into() });
|
||||
return Err(Error::AacsVidMac);
|
||||
}
|
||||
|
||||
auth.volume_id = Some(vid);
|
||||
@@ -1000,7 +1000,7 @@ pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result
|
||||
// REPORT DISC STRUCTURE format 0x84
|
||||
let cdb = cdb_report_disc_structure(auth.agid, 0x84, 36);
|
||||
let response = scsi_read(session, &cdb, 36)
|
||||
.map_err(|_| Error::AacsError { detail: "failed to read data keys".into() })?;
|
||||
.map_err(|_| Error::AacsDataKey)?;
|
||||
|
||||
let mut enc_rdk = [0u8; 16];
|
||||
let mut enc_wdk = [0u8; 16];
|
||||
|
||||
+2
-2
@@ -114,11 +114,11 @@ impl ClipInfo {
|
||||
/// 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() });
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
|
||||
if &data[0..4] != b"HDMV" {
|
||||
return Err(Error::DiscError { detail: "not a CLPI file".into() });
|
||||
return Err(Error::ClpiParse);
|
||||
}
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
|
||||
|
||||
+199
-147
@@ -1,4 +1,4 @@
|
||||
//! Disc structure — scan titles, streams, and sector ranges from a Blu-ray disc.
|
||||
//! 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.
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::drive::DriveSession;
|
||||
use crate::speed::DriveSpeed;
|
||||
use crate::udf;
|
||||
use crate::mpls;
|
||||
use crate::clpi;
|
||||
@@ -36,7 +37,7 @@ pub struct Disc {
|
||||
pub titles: Vec<Title>,
|
||||
/// Disc region
|
||||
pub region: DiscRegion,
|
||||
/// AACS state — None if disc is unencrypted or keys unavailable
|
||||
/// AACS state -- None if disc is unencrypted or keys unavailable
|
||||
pub aacs: Option<AacsState>,
|
||||
/// Whether this disc requires AACS decryption
|
||||
pub encrypted: bool,
|
||||
@@ -69,11 +70,11 @@ pub enum DiscRegion {
|
||||
/// Blu-ray region codes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
/// Region A/1 -- Americas, East Asia (Japan, Korea, Southeast Asia)
|
||||
A,
|
||||
/// Region B/2 — Europe, Africa, Australia, Middle East
|
||||
/// Region B/2 -- Europe, Africa, Australia, Middle East
|
||||
B,
|
||||
/// Region C/3 — Central/South Asia, China, Russia
|
||||
/// Region C/3 -- Central/South Asia, China, Russia
|
||||
C,
|
||||
}
|
||||
|
||||
@@ -309,7 +310,7 @@ pub struct AacsState {
|
||||
pub bus_encryption: bool,
|
||||
/// MKB version from disc (e.g. 68, 77)
|
||||
pub mkb_version: Option<u32>,
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) — hex string with 0x prefix
|
||||
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
|
||||
pub disc_hash: String,
|
||||
/// How keys were resolved
|
||||
pub key_source: KeySource,
|
||||
@@ -317,9 +318,9 @@ pub struct AacsState {
|
||||
pub vuk: [u8; 16],
|
||||
/// Decrypted unit keys (CPS unit number, key)
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
/// Read data key for AACS 2.0 bus decryption — None for AACS 1.0
|
||||
/// Read data key for AACS 2.0 bus decryption -- None for AACS 1.0
|
||||
pub read_data_key: Option<[u8; 16]>,
|
||||
/// Volume ID (16 bytes) — from SCSI handshake
|
||||
/// Volume ID (16 bytes) -- from SCSI handshake
|
||||
pub volume_id: [u8; 16],
|
||||
}
|
||||
|
||||
@@ -391,30 +392,84 @@ impl ScanOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A disc with an active drive session -- the main API.
|
||||
///
|
||||
/// Owns both the disc metadata and the drive connection.
|
||||
/// Created by `Disc::open()`. Provides `rip()` to read title data.
|
||||
pub struct OpenDisc {
|
||||
pub disc: Disc,
|
||||
pub session: DriveSession,
|
||||
}
|
||||
|
||||
impl OpenDisc {
|
||||
/// Open a drive, wait for disc, initialize, probe, and scan.
|
||||
/// This is the single entry point -- one call does everything.
|
||||
///
|
||||
pub fn open(device: &str, keydb_path: Option<&str>) -> Result<Self> {
|
||||
use std::path::Path;
|
||||
|
||||
let mut session = DriveSession::open(Path::new(device))?;
|
||||
session.wait_ready()?;
|
||||
|
||||
// Init (unlock + firmware) -- non-fatal if fails
|
||||
let _ = session.init();
|
||||
let _ = session.probe_disc();
|
||||
|
||||
let opts = if let Some(kp) = keydb_path {
|
||||
ScanOptions::with_keydb(kp)
|
||||
} else {
|
||||
ScanOptions::default()
|
||||
};
|
||||
|
||||
let disc = Disc::scan(&mut session, &opts)?;
|
||||
Ok(Self { disc, session })
|
||||
}
|
||||
|
||||
/// Rip a title to any output stream.
|
||||
///
|
||||
/// Reads sectors from disc, decrypts AACS, handles errors/retries,
|
||||
/// and writes decrypted BD-TS bytes to the output.
|
||||
/// Knows nothing about the output format -- just calls `write_all()`.
|
||||
///
|
||||
pub fn rip(&mut self, title_idx: usize, mut output: impl std::io::Write) -> Result<()> {
|
||||
let mut reader = self.disc.open_title(&mut self.session, title_idx)?;
|
||||
|
||||
loop {
|
||||
match reader.read_batch() {
|
||||
Ok(Some(batch)) => {
|
||||
output.write_all(batch).map_err(|_| Error::WriteError)?;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
// ContentReader handles retries internally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Total bytes for a title (for progress tracking).
|
||||
pub fn title_size(&self, title_idx: usize) -> u64 {
|
||||
self.disc.titles.get(title_idx)
|
||||
.map(|t| t.size_bytes)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Scan a disc — parse filesystem, playlists, streams, and set up AACS decryption.
|
||||
/// Scan a disc -- parse filesystem, playlists, streams, and set up AACS decryption.
|
||||
///
|
||||
/// This is the main entry point. After scan(), the Disc is ready:
|
||||
/// - titles are populated with streams
|
||||
/// - AACS keys are derived (if KEYDB available)
|
||||
/// - content can be read and decrypted transparently
|
||||
///
|
||||
/// ```no_run
|
||||
/// use libfreemkv::{DriveSession, Disc};
|
||||
/// use libfreemkv::disc::ScanOptions;
|
||||
/// use std::path::Path;
|
||||
///
|
||||
/// let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// for title in &disc.titles {
|
||||
/// println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
/// }
|
||||
/// ```
|
||||
/// Scan a disc. One pipeline, one order:
|
||||
/// 1. Read capacity
|
||||
/// 2. Read UDF filesystem
|
||||
@@ -423,17 +478,15 @@ impl Disc {
|
||||
/// 5. Apply labels
|
||||
///
|
||||
/// The session must be open and unlocked (DriveSession::open handles this).
|
||||
/// All disc reads use standard READ(10) via UDF — no vendor SCSI commands.
|
||||
/// All disc reads use standard READ(10) via UDF -- no vendor SCSI commands.
|
||||
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
// 1. Capacity
|
||||
let capacity = Self::read_capacity(session)?;
|
||||
|
||||
// 2. UDF filesystem
|
||||
let udf_fs = udf::read_filesystem(session)?;
|
||||
|
||||
// 3. AACS — read files from disc via UDF, resolve keys via KEYDB
|
||||
// 3. AACS -- read files from disc via UDF, resolve keys via KEYDB
|
||||
let encrypted = udf_fs.find_dir("/AACS").is_some()
|
||||
|| udf_fs.find_dir("/BDMV/AACS").is_some();
|
||||
|
||||
@@ -495,16 +548,12 @@ impl Disc {
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
|
||||
detail: format!("failed to load KEYDB: {}", e),
|
||||
})?;
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad { path: keydb_path.display().to_string() })?;
|
||||
|
||||
// Read AACS files from disc via UDF (standard READ(10), no vendor commands)
|
||||
let uk_ro_data = udf_fs.read_file(session, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsError {
|
||||
detail: "Unit_Key_RO.inf not found on disc".into(),
|
||||
})?;
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
|
||||
@@ -515,17 +564,34 @@ impl Disc {
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
// AACS SCSI handshake — get Volume ID (and read data key for AACS 2.0)
|
||||
let mut volume_id = [0u8; 16];
|
||||
let mut read_data_key = None;
|
||||
|
||||
if let Some(ref hc) = keydb.host_cert {
|
||||
if let Ok(mut auth) = aacs::handshake::aacs_authenticate(
|
||||
session, &hc.private_key, &hc.certificate,
|
||||
) {
|
||||
// Read Volume ID (needed for MK → VUK derivation)
|
||||
if let Ok(vid) = aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
volume_id = vid;
|
||||
}
|
||||
|
||||
// Read data keys for bus decryption (AACS 2.0 / UHD)
|
||||
if let Ok((rdk, _wdk)) = aacs::handshake::read_data_keys(session, &mut auth) {
|
||||
read_data_key = Some(rdk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve: disc hash → KEYDB lookup → VUK → unit keys
|
||||
let vid_zero = [0u8; 16];
|
||||
let resolved = aacs::resolve_keys(
|
||||
&uk_ro_data,
|
||||
cc_data.as_deref(),
|
||||
&vid_zero,
|
||||
&volume_id,
|
||||
&keydb,
|
||||
mkb_data.as_deref(),
|
||||
).ok_or_else(|| Error::AacsError {
|
||||
detail: "disc not in KEYDB".into(),
|
||||
})?;
|
||||
).ok_or_else(|| Error::AacsNoKeys)?;
|
||||
|
||||
Ok(AacsState {
|
||||
version: if resolved.aacs2 { 2 } else { 1 },
|
||||
@@ -541,8 +607,8 @@ impl Disc {
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key: None,
|
||||
volume_id: [0u8; 16],
|
||||
read_data_key,
|
||||
volume_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -605,7 +671,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
|
||||
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let cdb = [crate::scsi::SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut buf = [0u8; 8];
|
||||
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
@@ -671,10 +737,12 @@ impl Disc {
|
||||
}
|
||||
|
||||
// Build streams from STN table
|
||||
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
|
||||
let streams: Vec<Stream> = parsed.streams.iter().filter_map(|s| {
|
||||
// Skip empty/padding entries (coding_type 0x00)
|
||||
if s.coding_type == 0 { return None; }
|
||||
let codec = Codec::from_coding_type(s.coding_type);
|
||||
match s.stream_type {
|
||||
1 | 6 | 7 => Stream::Video(VideoStream {
|
||||
1 | 6 | 7 => Some(Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: format_resolution(s.video_format, s.video_rate),
|
||||
@@ -694,32 +762,37 @@ impl Disc {
|
||||
7 => "Dolby Vision EL".to_string(),
|
||||
_ => String::new(),
|
||||
},
|
||||
}),
|
||||
2 | 5 => Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}),
|
||||
3 => Stream::Subtitle(SubtitleStream {
|
||||
})),
|
||||
2 | 5 => {
|
||||
// Guard: if coding_type is a subtitle codec (PGS 0x90/0x91),
|
||||
// this is a misaligned stream -- treat as subtitle, not audio
|
||||
if matches!(codec, Codec::Pgs) {
|
||||
Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false,
|
||||
}))
|
||||
} else {
|
||||
Some(Stream::Audio(AudioStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
channels: format_channels(s.audio_format),
|
||||
language: s.language.clone(),
|
||||
sample_rate: format_samplerate(s.audio_rate),
|
||||
secondary: s.stream_type == 5,
|
||||
label: String::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
3 => Some(Stream::Subtitle(SubtitleStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
language: s.language.clone(),
|
||||
forced: false, // TODO: parse from MPLS stream attributes
|
||||
}),
|
||||
_ => Stream::Video(VideoStream {
|
||||
pid: s.pid,
|
||||
codec,
|
||||
resolution: String::new(),
|
||||
frame_rate: String::new(),
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
}),
|
||||
forced: false,
|
||||
})),
|
||||
// Stream type 4 = IG, unknown types -- skip
|
||||
_ => None,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
@@ -760,46 +833,32 @@ pub struct ContentReader<'a> {
|
||||
buf_len: usize,
|
||||
/// Current batch size in sectors (adapts on errors)
|
||||
batch_sectors: u16,
|
||||
/// Maximum batch size detected from kernel limits
|
||||
max_batch_sectors: u16,
|
||||
/// Consecutive successful batch reads
|
||||
ok_streak: u32,
|
||||
/// Consecutive errors at current position
|
||||
error_streak: u32,
|
||||
/// Current speed tier index (0 = max, higher = slower)
|
||||
speed_tier: usize,
|
||||
/// Last time maintain_speed was called
|
||||
last_speed_maintain: std::time::Instant,
|
||||
/// Total read errors encountered
|
||||
pub errors: u32,
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Open a title for reading. Decryption is automatic — if the disc
|
||||
/// Open a title for reading. Decryption is automatic -- if the disc
|
||||
/// is encrypted and keys were found during scan(), content is decrypted
|
||||
/// on the fly. Unencrypted discs pass through unchanged.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use libfreemkv::{DriveSession, Disc};
|
||||
/// # use libfreemkv::disc::ScanOptions;
|
||||
/// # use std::path::Path;
|
||||
/// # let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
|
||||
/// let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
/// let mut reader = disc.open_title(&mut session, 0).unwrap();
|
||||
/// while let Some(unit) = reader.read_unit().unwrap() {
|
||||
/// // unit is 6144 bytes of decrypted content
|
||||
/// }
|
||||
/// ```
|
||||
pub fn open_title<'a>(&'a self, session: &'a mut DriveSession, title_idx: usize) -> Result<ContentReader<'a>> {
|
||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscError {
|
||||
detail: format!("title index {} out of range (have {})", title_idx, self.titles.len()),
|
||||
})?;
|
||||
let title = self.titles.get(title_idx).ok_or_else(|| Error::DiscTitleRange { index: title_idx, count: self.titles.len() })?;
|
||||
|
||||
if !session.is_unlocked() {
|
||||
let _ = session.init();
|
||||
}
|
||||
// Let the drive manage its own read speed after init.
|
||||
// SET_CD_SPEED is only used reactively by the error handler to slow
|
||||
// down on read errors, then let the drive recover.
|
||||
|
||||
let speed_cdb = crate::scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = session.scsi_execute(&speed_cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
// Detect kernel max transfer size for this device
|
||||
let max_batch = detect_max_batch_sectors(session.device_path());
|
||||
|
||||
Ok(ContentReader {
|
||||
session,
|
||||
@@ -808,14 +867,13 @@ impl Disc {
|
||||
current_extent: 0,
|
||||
current_offset: 0,
|
||||
unit_key_idx: 0,
|
||||
read_buf: Vec::with_capacity(MAX_BATCH_SECTORS as usize * 2048),
|
||||
read_buf: Vec::with_capacity(max_batch as usize * 2048),
|
||||
buf_pos: 0,
|
||||
buf_len: 0,
|
||||
batch_sectors: MAX_BATCH_SECTORS,
|
||||
batch_sectors: max_batch,
|
||||
max_batch_sectors: max_batch,
|
||||
ok_streak: 0,
|
||||
error_streak: 0,
|
||||
speed_tier: 0,
|
||||
last_speed_maintain: std::time::Instant::now(),
|
||||
errors: 0,
|
||||
})
|
||||
}
|
||||
@@ -823,17 +881,32 @@ impl Disc {
|
||||
|
||||
/// Detect the maximum transfer size in sectors for a device.
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
||||
/// Returns a value aligned to 3 sectors (one aligned unit).
|
||||
fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
// Extract block device name: /dev/sr0 → sr0
|
||||
let dev_name = device_path.rsplit('/').next().unwrap_or("");
|
||||
if !dev_name.is_empty() {
|
||||
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", dev_name);
|
||||
if dev_name.is_empty() {
|
||||
return DEFAULT_BATCH_SECTORS;
|
||||
}
|
||||
|
||||
// For sg devices, find the corresponding block device name
|
||||
let block_name = if dev_name.starts_with("sg") {
|
||||
let block_dir = format!("/sys/class/scsi_generic/{}/device/block", dev_name);
|
||||
std::fs::read_dir(&block_dir).ok()
|
||||
.and_then(|mut entries| entries.next())
|
||||
.and_then(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
} else {
|
||||
Some(dev_name.to_string())
|
||||
};
|
||||
|
||||
if let Some(bname) = block_name {
|
||||
let sysfs_path = format!("/sys/block/{}/queue/max_hw_sectors_kb", bname);
|
||||
if let Ok(content) = std::fs::read_to_string(&sysfs_path) {
|
||||
if let Ok(kb) = content.trim().parse::<u32>() {
|
||||
// Convert KB to sectors (1 sector = 2 KB on disc = 2048 bytes)
|
||||
// Convert KB to sectors (1 sector = 2 KB = 2048 bytes)
|
||||
let sectors = (kb / 2) as u16;
|
||||
// Align down to 3 (one aligned unit) and cap at a reasonable max
|
||||
// Align down to 3 (one aligned unit)
|
||||
let aligned = (sectors / 3) * 3;
|
||||
if aligned >= MIN_BATCH_SECTORS {
|
||||
return aligned.min(MAX_BATCH_SECTORS);
|
||||
@@ -841,26 +914,18 @@ fn detect_max_batch_sectors(device_path: &str) -> u16 {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: conservative default
|
||||
MAX_BATCH_SECTORS
|
||||
// Fallback: safe default well under typical kernel limits
|
||||
DEFAULT_BATCH_SECTORS
|
||||
}
|
||||
|
||||
/// Read strategy constants
|
||||
const MAX_BATCH_SECTORS: u16 = 510; // 170 aligned units ≈ 1MB (kernel caps to hw limit)
|
||||
const MAX_BATCH_SECTORS: u16 = 510; // absolute max (170 aligned units ≈ 1MB)
|
||||
const DEFAULT_BATCH_SECTORS: u16 = 60; // fallback: typical kernel limit (120KB = 60 sectors)
|
||||
const MIN_BATCH_SECTORS: u16 = 3; // 1 aligned unit = 6KB (error recovery)
|
||||
const RAMP_BATCH_AFTER: u32 = 5; // successes before doubling batch size
|
||||
const RAMP_SPEED_AFTER: u32 = 50; // successes at max batch before restoring speed
|
||||
const SLOW_SPEED_AFTER: u32 = 3; // consecutive errors before reducing disc speed
|
||||
|
||||
/// Disc speed tiers (KB/s for SET CD SPEED).
|
||||
/// Blu-ray: 1x=4500, 2x=9000, 4x=18000, 8x=36000, 12x=54000
|
||||
const SPEED_TIERS: &[u16] = &[
|
||||
0xFFFF, // tier 0: max (drive decides, typically 8-12x)
|
||||
36000, // tier 1: 8x BD (~36 MB/s)
|
||||
18000, // tier 2: 4x BD (~18 MB/s)
|
||||
9000, // tier 3: 2x BD (~9 MB/s)
|
||||
4500, // tier 4: 1x BD (~4.5 MB/s) — last resort
|
||||
];
|
||||
|
||||
impl<'a> ContentReader<'a> {
|
||||
/// Total bytes across all extents (for progress display).
|
||||
@@ -892,7 +957,7 @@ impl<'a> ContentReader<'a> {
|
||||
|
||||
/// Read the next batch of aligned units, decrypted in-place.
|
||||
/// Returns the decrypted data as a single contiguous slice.
|
||||
/// More efficient than read_unit() — one write_all() per batch instead of per unit.
|
||||
/// More efficient than read_unit() -- one write_all() per batch instead of per unit.
|
||||
/// Returns None when all extents are exhausted.
|
||||
pub fn read_batch(&mut self) -> Result<Option<&[u8]>> {
|
||||
if !self.fill_buffer()? {
|
||||
@@ -946,29 +1011,13 @@ impl<'a> ContentReader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set disc spin speed via SCSI SET CD SPEED.
|
||||
fn set_speed(&mut self, tier: usize) {
|
||||
let tier = tier.min(SPEED_TIERS.len() - 1);
|
||||
if tier != self.speed_tier {
|
||||
self.speed_tier = tier;
|
||||
let speed_kbs = SPEED_TIERS[tier];
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = self.session.scsi_execute(
|
||||
&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a batch of sectors into the internal buffer.
|
||||
///
|
||||
/// Adaptive strategy:
|
||||
/// 1. Read at current batch size
|
||||
/// 2. On success: ramp batch up (double after 5 successes),
|
||||
/// then restore disc speed (after 50 at max batch)
|
||||
/// 3. On error: halve batch, pause. After 3 consecutive errors,
|
||||
/// also reduce disc spin speed (scratched/damaged region).
|
||||
/// 4. At min batch + still failing: retry once, then skip + zero-fill.
|
||||
/// Error handling:
|
||||
/// - First error: re-init drive (may have re-locked), halve batch
|
||||
/// - Repeated errors: reduce speed, keep halving batch
|
||||
/// - At minimum batch: retry once, then skip + zero-fill
|
||||
/// - After sustained success: ramp batch back up, restore max speed
|
||||
fn fill_buffer(&mut self) -> Result<bool> {
|
||||
loop {
|
||||
if self.current_extent >= self.extents.len() {
|
||||
@@ -1004,16 +1053,16 @@ impl<'a> ContentReader<'a> {
|
||||
self.current_offset = 0;
|
||||
}
|
||||
|
||||
// Ramp up: batch size first, then disc speed
|
||||
// Ramp up batch size after consecutive successes
|
||||
self.ok_streak += 1;
|
||||
if self.batch_sectors < MAX_BATCH_SECTORS {
|
||||
if self.ok_streak >= RAMP_BATCH_AFTER {
|
||||
self.batch_sectors = (self.batch_sectors * 2).min(MAX_BATCH_SECTORS);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
} else if self.speed_tier > 0 && self.ok_streak >= RAMP_SPEED_AFTER {
|
||||
// At max batch for a while — try faster disc speed
|
||||
self.set_speed(self.speed_tier - 1);
|
||||
if self.batch_sectors < self.max_batch_sectors && self.ok_streak >= RAMP_BATCH_AFTER {
|
||||
self.batch_sectors = (self.batch_sectors * 2).min(self.max_batch_sectors);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
// Restore max speed after sustained success at full batch
|
||||
if self.batch_sectors == self.max_batch_sectors && self.ok_streak >= RAMP_SPEED_AFTER {
|
||||
self.session.set_speed(0xFFFF);
|
||||
self.ok_streak = 0;
|
||||
}
|
||||
|
||||
@@ -1024,20 +1073,23 @@ impl<'a> ContentReader<'a> {
|
||||
self.error_streak += 1;
|
||||
self.ok_streak = 0;
|
||||
|
||||
// Reduce disc speed after repeated errors (physical problem)
|
||||
if self.error_streak >= SLOW_SPEED_AFTER
|
||||
&& self.speed_tier < SPEED_TIERS.len() - 1
|
||||
{
|
||||
self.set_speed(self.speed_tier + 1);
|
||||
self.error_streak = 0; // reset — give new speed a chance
|
||||
// First error: re-init (drive may have re-locked)
|
||||
if self.error_streak == 1 {
|
||||
let _ = self.session.init();
|
||||
let _ = self.session.probe_disc();
|
||||
}
|
||||
|
||||
// Repeated errors: slow down
|
||||
if self.error_streak >= SLOW_SPEED_AFTER {
|
||||
self.session.set_speed(DriveSpeed::BD2x.to_kbps());
|
||||
self.error_streak = 0;
|
||||
}
|
||||
|
||||
if self.batch_sectors > MIN_BATCH_SECTORS {
|
||||
// Shrink batch and retry
|
||||
self.batch_sectors = (self.batch_sectors / 2).max(MIN_BATCH_SECTORS);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
} else {
|
||||
// At minimum batch — retry once with longer pause
|
||||
// At minimum batch -- retry once with longer pause
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
self.read_buf.resize(MIN_BATCH_SECTORS as usize * 2048, 0);
|
||||
if self.read_sectors(lba, MIN_BATCH_SECTORS).is_ok() {
|
||||
@@ -1051,7 +1103,7 @@ impl<'a> ContentReader<'a> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
// Still failing — skip this unit (zero-fill)
|
||||
// Still failing -- skip this unit (zero-fill)
|
||||
self.current_offset += 3;
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
|
||||
+83
-150
@@ -1,78 +1,47 @@
|
||||
//! Drive session — open, identify, unlock, and read from optical drives.
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! `DriveSession` is the entry point for all drive interaction. It handles
|
||||
//! device identification, profile matching, platform-specific unlock, and
|
||||
//! provides both raw sector reads and standard SCSI command execution.
|
||||
//!
|
||||
//! Two open modes:
|
||||
//! - `open()` — identify + unlock. Ready for reading immediately.
|
||||
//! - `open_no_unlock()` — identify only. Used for AACS authentication
|
||||
//! which must happen before the drive enters raw mode.
|
||||
//! Three-step open:
|
||||
//! 1. `open()` — open device, identify drive. Always OEM.
|
||||
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
|
||||
//! 3. `init()` — activate custom firmware. Removes riplock.
|
||||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||||
|
||||
use std::path::Path;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::identity::DriveId;
|
||||
use crate::profile::{self, DriveProfile, Chipset};
|
||||
use crate::platform::{Platform, DriveStatus};
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
|
||||
/// A drive session with identification, platform, and SCSI transport.
|
||||
///
|
||||
/// Created via `DriveSession::open()` or `DriveSession::open_no_unlock()`.
|
||||
/// All disc reading goes through this struct.
|
||||
pub struct DriveSession {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
platform: Box<dyn Platform>,
|
||||
pub profile: DriveProfile,
|
||||
driver: Option<Box<dyn PlatformDriver>>,
|
||||
pub profile: Option<DriveProfile>,
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
}
|
||||
|
||||
impl DriveSession {
|
||||
/// Open a drive — identify, wait for disc, and unlock for raw reads.
|
||||
///
|
||||
/// This is the standard entry point. After `open()`, the drive is
|
||||
/// ready for scanning and content reads.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive and immediately init for raw reads.
|
||||
///
|
||||
/// Use this when you need raw disc access without AACS (e.g. capture,
|
||||
/// sector dumps). Skips AACS authentication — cannot be done after init.
|
||||
pub fn open_unlocked(device: &Path) -> Result<Self> {
|
||||
let mut session = Self::open_no_unlock(device)?;
|
||||
session.wait_ready()?;
|
||||
let _ = session.init();
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Open a drive — identify only, no wait, no unlock.
|
||||
///
|
||||
/// Low-level entry point. Caller is responsible for wait_ready()
|
||||
/// and unlock() ordering.
|
||||
pub fn open_no_unlock(device: &Path) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let profiles = profile::load_bundled()?;
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
|
||||
let profile = profile::find_by_drive_id(&profiles, &drive_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
product_revision: drive_id.product_revision.trim().to_string(),
|
||||
})?;
|
||||
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
let m = profile::find_by_drive_id(&profiles, &drive_id);
|
||||
let (driver, platform, profile) = match m {
|
||||
Some(m) => (
|
||||
create_driver(m.platform, &m.profile).ok(),
|
||||
Some(m.platform),
|
||||
Some(m.profile),
|
||||
),
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
driver,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
@@ -80,25 +49,13 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Open with an explicit profile, skipping auto-detection.
|
||||
pub fn open_with_profile(device: &Path, profile: DriveProfile) -> Result<Self> {
|
||||
let mut transport = crate::scsi::open(device)?;
|
||||
let drive_id = DriveId::from_drive(transport.as_mut())?;
|
||||
let platform = create_platform(&profile, &drive_id)?;
|
||||
|
||||
Ok(DriveSession {
|
||||
scsi: transport,
|
||||
platform,
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
})
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
}
|
||||
|
||||
/// Wait for the drive to become ready (disc spun up).
|
||||
/// Polls TEST UNIT READY up to 30 seconds.
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // TEST UNIT READY
|
||||
let tur = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
if self.scsi.as_mut().execute(
|
||||
@@ -113,109 +70,105 @@ impl DriveSession {
|
||||
})
|
||||
}
|
||||
|
||||
/// Device path this session was opened on.
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
None => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_path(&self) -> &str {
|
||||
&self.device_path
|
||||
}
|
||||
|
||||
///
|
||||
/// This is the ONLY entry point for activating raw disc access.
|
||||
/// Handles the full x86 dispatch sequence internally:
|
||||
/// unlock → [load_firmware if cold] × 6 → calibrate × 6 → registers
|
||||
/// Initialize drive — unlock + firmware upload.
|
||||
/// Optional. Adds features: removes riplock, enables UHD reads, speed control.
|
||||
pub fn init(&mut self) -> Result<()> {
|
||||
self.platform.init(self.scsi.as_mut())
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.init(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if raw disc access mode is active.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.platform.is_unlocked()
|
||||
/// Probe disc surface so the drive firmware learns optimal read speeds
|
||||
/// per region. After this the host reads at max speed and the drive
|
||||
/// manages zones internally.
|
||||
pub fn probe_disc(&mut self) -> Result<()> {
|
||||
match self.driver {
|
||||
Some(ref mut d) => d.probe_disc(self.scsi.as_mut()),
|
||||
None => Err(Error::UnsupportedDrive {
|
||||
vendor_id: self.drive_id.vendor_id.trim().to_string(),
|
||||
product_id: self.drive_id.product_id.trim().to_string(),
|
||||
product_revision: self.drive_id.product_revision.trim().to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Called per zone change during content reads.
|
||||
pub fn set_read_speed(&mut self, lba: u32) -> Result<()> {
|
||||
self.platform.set_read_speed(self.scsi.as_mut(), lba)
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match self.driver {
|
||||
Some(ref d) => d.is_ready(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// SCSI READ(10) for disc filesystem data (UDF, MPLS, CLPI).
|
||||
pub fn read_disc(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
0x00, (count >> 8) as u8, count as u8, 0x00,
|
||||
];
|
||||
let result = self.scsi.as_mut().execute(
|
||||
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// SCSI READ(10) for m2ts content — bulk reads with longer timeout.
|
||||
pub fn read_content(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10, 0x00,
|
||||
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8, count as u8,
|
||||
0x00,
|
||||
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)
|
||||
}
|
||||
|
||||
/// Eject the disc tray.
|
||||
///
|
||||
/// Sends PREVENT ALLOW MEDIUM REMOVAL (allow) first to release any
|
||||
/// locks, then START STOP UNIT with LoEj=1 to open the tray.
|
||||
pub fn set_speed(&mut self, speed_kbs: u16) {
|
||||
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = self.scsi_execute(&cdb, crate::scsi::DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
pub fn eject(&mut self) -> Result<()> {
|
||||
// PREVENT ALLOW MEDIUM REMOVAL: allow removal
|
||||
let allow_cdb = [0x1Eu8, 0, 0, 0, 0x00, 0];
|
||||
let mut buf = [0u8; 0];
|
||||
let _ = self.scsi.as_mut().execute(&allow_cdb, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
|
||||
// START STOP UNIT: LoEj=1, Start=0
|
||||
let eject_cdb = [0x1Bu8, 0, 0, 0, 0x02, 0];
|
||||
self.scsi.as_mut().execute(&eject_cdb, crate::scsi::DataDirection::None, &mut buf, 30_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a raw SCSI CDB. Used by parsers and AACS handshake.
|
||||
pub fn scsi_execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: crate::scsi::DataDirection,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
&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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover optical drives on the system.
|
||||
///
|
||||
/// Scans `/dev/sg0` through `/dev/sg15` (Linux SCSI Generic devices),
|
||||
/// sends INQUIRY to each, and returns paths for optical drives (device type 5).
|
||||
/// Always uses sg devices — sr devices have kernel-level speed management
|
||||
/// that interferes with raw disc access.
|
||||
///
|
||||
/// Returns a list of (device_path, DriveId) for each found drive.
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/sg{}", i);
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
if !std::path::Path::new(&path).exists() { continue; }
|
||||
if let Ok(mut transport) = crate::scsi::open(std::path::Path::new(&path)) {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
// INQUIRY device type 5 = CD/DVD/BD
|
||||
// We check by trying to match a profile — only optical drives have profiles
|
||||
let profiles = match profile::load_bundled() {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if profile::find_by_drive_id(&profiles, &id).is_some() {
|
||||
// Include all optical drives (peripheral device type 0x05)
|
||||
if id.raw_inquiry.len() > 0 && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
}
|
||||
@@ -224,69 +177,49 @@ pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
drives
|
||||
}
|
||||
|
||||
/// Find the first optical drive on the system.
|
||||
/// Returns the sg device path, or None if no drive found.
|
||||
pub fn find_drive() -> Option<String> {
|
||||
find_drives().into_iter().next().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
/// Resolve a device path to the correct sg device.
|
||||
///
|
||||
/// If the user passes `/dev/sr0`, maps it to the corresponding `/dev/sg*`.
|
||||
/// If they pass `/dev/sg*`, validates it exists.
|
||||
/// Returns `(resolved_path, warning)` where warning is set if the path was remapped.
|
||||
pub fn resolve_device(path: &str) -> Result<(String, Option<String>)> {
|
||||
// Already an sg device — use as-is
|
||||
if path.contains("/sg") {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
return Ok((path.to_string(), None));
|
||||
}
|
||||
|
||||
// sr device — find the matching sg device by comparing INQUIRY data
|
||||
if path.contains("/sr") {
|
||||
// Open the sr device to get its identity
|
||||
let mut sr_transport = crate::scsi::open(std::path::Path::new(path))?;
|
||||
let sr_id = DriveId::from_drive(sr_transport.as_mut())?;
|
||||
drop(sr_transport);
|
||||
|
||||
// Find matching sg device
|
||||
for (sg_path, sg_id) in find_drives() {
|
||||
if sg_id.vendor_id == sr_id.vendor_id
|
||||
&& sg_id.product_id == sr_id.product_id
|
||||
&& sg_id.serial_number == sr_id.serial_number
|
||||
{
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — using {} (sg) for raw access",
|
||||
path, sg_path
|
||||
"{} is a block device (sr) — using {} (sg) for raw access", path, sg_path
|
||||
);
|
||||
return Ok((sg_path, Some(warning)));
|
||||
}
|
||||
}
|
||||
|
||||
// No sg match found — fall back to sr with warning
|
||||
let warning = format!(
|
||||
"{} is a block device (sr) — no matching sg device found, performance may be limited",
|
||||
path
|
||||
);
|
||||
return Ok((path.to_string(), Some(warning)));
|
||||
return Ok((path.to_string(), Some(format!(
|
||||
"{} is a block device (sr) — no matching sg device found", path
|
||||
))));
|
||||
}
|
||||
|
||||
// Unknown device type — use as-is
|
||||
if !std::path::Path::new(path).exists() {
|
||||
return Err(Error::DeviceNotFound { path: path.to_string() });
|
||||
}
|
||||
Ok((path.to_string(), None))
|
||||
}
|
||||
|
||||
/// Create the platform-specific driver for a given chipset.
|
||||
fn create_platform(profile: &DriveProfile, drive_id: &DriveId) -> Result<Box<dyn Platform>> {
|
||||
match profile.chipset {
|
||||
Chipset::MediaTek => Ok(Box::new(Mt1959::new(profile.clone()))),
|
||||
Chipset::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: drive_id.vendor_id.trim().to_string(),
|
||||
product_id: drive_id.product_id.trim().to_string(),
|
||||
fn create_driver(platform: profile::Platform, profile: &DriveProfile) -> Result<Box<dyn PlatformDriver>> {
|
||||
match platform {
|
||||
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
|
||||
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
|
||||
profile::Platform::Renesas => Err(Error::UnsupportedDrive {
|
||||
vendor_id: profile.identity.vendor_id.trim().to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: "Renesas not yet implemented".to_string(),
|
||||
}),
|
||||
}
|
||||
|
||||
+126
-47
@@ -1,8 +1,7 @@
|
||||
//! Error types for libfreemkv.
|
||||
//!
|
||||
//! Every error carries a numeric code for programmatic handling.
|
||||
//! No user-facing English text — applications format their own messages.
|
||||
//! This keeps the library locale-independent and testable.
|
||||
//! Every error is a code with structured data. No English text.
|
||||
//! Applications map codes to localized messages.
|
||||
//!
|
||||
//! # Error Code Ranges
|
||||
//!
|
||||
@@ -15,8 +14,10 @@
|
||||
//! | E5xxx | I/O errors |
|
||||
//! | E6xxx | Disc format errors |
|
||||
//! | E7xxx | AACS errors |
|
||||
//! | E8xxx | Keydb errors |
|
||||
//! | E9xxx | Mux errors |
|
||||
|
||||
// ── Error codes (single source of truth) ────────────────────────────────────
|
||||
// ── Error codes ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
@@ -30,116 +31,194 @@ pub const E_NOT_CALIBRATED: u16 = 3003;
|
||||
pub const E_SCSI_ERROR: u16 = 4000;
|
||||
pub const E_SCSI_TIMEOUT: u16 = 4001;
|
||||
pub const E_IO_ERROR: u16 = 5000;
|
||||
pub const E_DISC_ERROR: u16 = 6000;
|
||||
pub const E_AACS_ERROR: u16 = 7000;
|
||||
pub const E_WRITE_ERROR: u16 = 5001;
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
pub const E_DISC_NO_TITLES: u16 = 6004;
|
||||
pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_DISC_NO_EXTENTS: u16 = 6006;
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
pub const E_AACS_CERT_SHORT: u16 = 7001;
|
||||
pub const E_AACS_AGID_ALLOC: u16 = 7002;
|
||||
pub const E_AACS_CERT_REJECTED: u16 = 7003;
|
||||
pub const E_AACS_CERT_READ: u16 = 7004;
|
||||
pub const E_AACS_CERT_VERIFY: u16 = 7005;
|
||||
pub const E_AACS_KEY_READ: u16 = 7006;
|
||||
pub const E_AACS_KEY_REJECTED: u16 = 7007;
|
||||
pub const E_AACS_KEY_VERIFY: u16 = 7008;
|
||||
pub const E_AACS_VID_READ: u16 = 7009;
|
||||
pub const E_AACS_VID_MAC: u16 = 7010;
|
||||
pub const E_AACS_DATA_KEY: u16 = 7011;
|
||||
pub const E_AACS_VUK_DERIVE: u16 = 7012;
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
pub const E_KEYDB_HTTP: u16 = 8001;
|
||||
pub const E_KEYDB_INVALID: u16 = 8002;
|
||||
pub const E_KEYDB_WRITE: u16 = 8003;
|
||||
pub const E_KEYDB_PARSE: u16 = 8004;
|
||||
pub const E_KEYDB_LOAD: u16 = 8005;
|
||||
// Mux (9xxx)
|
||||
pub const E_MUX_LOOKAHEAD: u16 = 9000;
|
||||
pub const E_MUX_WRITE: u16 = 9001;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Structured error with numeric code and context data.
|
||||
/// Structured error with numeric code and context data. No English text.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
// Device
|
||||
DeviceNotFound { path: String },
|
||||
DevicePermission { path: String },
|
||||
|
||||
// Profile
|
||||
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
|
||||
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
|
||||
ProfileParse { detail: String },
|
||||
UnlockFailed { detail: String },
|
||||
ProfileParse,
|
||||
|
||||
// Unlock
|
||||
UnlockFailed,
|
||||
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
|
||||
NotUnlocked,
|
||||
NotCalibrated,
|
||||
|
||||
// SCSI
|
||||
ScsiError { opcode: u8, status: u8, sense_key: u8 },
|
||||
ScsiTimeout { opcode: u8 },
|
||||
|
||||
// I/O
|
||||
IoError { source: std::io::Error },
|
||||
DiscError { detail: String },
|
||||
AacsError { detail: String },
|
||||
WriteError,
|
||||
|
||||
// Disc format
|
||||
DiscRead { sector: u64 },
|
||||
MplsParse,
|
||||
ClpiParse,
|
||||
UdfNotFound { path: String },
|
||||
DiscNoTitles,
|
||||
DiscTitleRange { index: usize, count: usize },
|
||||
DiscNoExtents,
|
||||
|
||||
// AACS
|
||||
AacsNoKeys,
|
||||
AacsCertShort,
|
||||
AacsAgidAlloc,
|
||||
AacsCertRejected,
|
||||
AacsCertRead,
|
||||
AacsCertVerify,
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
AacsVidRead,
|
||||
AacsVidMac,
|
||||
AacsDataKey,
|
||||
AacsVukDerive,
|
||||
|
||||
// Keydb
|
||||
KeydbConnect { host: String },
|
||||
KeydbHttp { status: u16 },
|
||||
KeydbInvalid,
|
||||
KeydbWrite { path: String },
|
||||
KeydbParse,
|
||||
KeydbLoad { path: String },
|
||||
|
||||
// Mux
|
||||
MuxLookahead,
|
||||
MuxWrite,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Numeric error code.
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::DeviceNotFound { .. } => E_DEVICE_NOT_FOUND,
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileNotFound { .. } => E_PROFILE_NOT_FOUND,
|
||||
Error::ProfileParse { .. } => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed { .. } => E_UNLOCK_FAILED,
|
||||
Error::ProfileParse => E_PROFILE_PARSE,
|
||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::NotUnlocked => E_NOT_UNLOCKED,
|
||||
Error::NotCalibrated => E_NOT_CALIBRATED,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::ScsiTimeout { .. } => E_SCSI_TIMEOUT,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::DiscError { .. } => E_DISC_ERROR,
|
||||
Error::AacsError { .. } => E_AACS_ERROR,
|
||||
Error::WriteError => E_WRITE_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
Error::DiscNoTitles => E_DISC_NO_TITLES,
|
||||
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
|
||||
Error::DiscNoExtents => E_DISC_NO_EXTENTS,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
Error::AacsCertRejected => E_AACS_CERT_REJECTED,
|
||||
Error::AacsCertRead => E_AACS_CERT_READ,
|
||||
Error::AacsCertVerify => E_AACS_CERT_VERIFY,
|
||||
Error::AacsKeyRead => E_AACS_KEY_READ,
|
||||
Error::AacsKeyRejected => E_AACS_KEY_REJECTED,
|
||||
Error::AacsKeyVerify => E_AACS_KEY_VERIFY,
|
||||
Error::AacsVidRead => E_AACS_VID_READ,
|
||||
Error::AacsVidMac => E_AACS_VID_MAC,
|
||||
Error::AacsDataKey => E_AACS_DATA_KEY,
|
||||
Error::AacsVukDerive => E_AACS_VUK_DERIVE,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
Error::KeydbWrite { .. } => E_KEYDB_WRITE,
|
||||
Error::KeydbParse => E_KEYDB_PARSE,
|
||||
Error::KeydbLoad { .. } => E_KEYDB_LOAD,
|
||||
Error::MuxLookahead => E_MUX_LOOKAHEAD,
|
||||
Error::MuxWrite => E_MUX_WRITE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display format: "E{code}: {context}" — terse, for logs.
|
||||
/// Applications should format their own user-facing messages using code() and fields.
|
||||
/// Display: "E{code}" with structured data. No English words.
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::DeviceNotFound { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DevicePermission { path } =>
|
||||
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
|
||||
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_id.trim(), product_revision.trim()),
|
||||
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
|
||||
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
|
||||
write!(f, "E{}: {} {} {}", self.code(),
|
||||
vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
|
||||
Error::ProfileParse { detail } =>
|
||||
write!(f, "E{}: {}", E_PROFILE_PARSE, detail),
|
||||
Error::UnlockFailed { detail } =>
|
||||
write!(f, "E{}: {}", E_UNLOCK_FAILED, detail),
|
||||
Error::SignatureMismatch { expected, got } =>
|
||||
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
|
||||
E_SIGNATURE_MISMATCH,
|
||||
write!(f, "E{}: {:02x}{:02x}{:02x}{:02x}!={:02x}{:02x}{:02x}{:02x}",
|
||||
self.code(),
|
||||
expected[0], expected[1], expected[2], expected[3],
|
||||
got[0], got[1], got[2], got[3]),
|
||||
Error::NotUnlocked =>
|
||||
write!(f, "E{}", E_NOT_UNLOCKED),
|
||||
Error::NotCalibrated =>
|
||||
write!(f, "E{}", E_NOT_CALIBRATED),
|
||||
Error::ScsiError { opcode, status, sense_key } =>
|
||||
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
|
||||
E_SCSI_ERROR, opcode, status, sense_key),
|
||||
write!(f, "E{}: 0x{:02x}/0x{:02x}/0x{:02x}", self.code(), opcode, status, sense_key),
|
||||
Error::ScsiTimeout { opcode } =>
|
||||
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, opcode),
|
||||
write!(f, "E{}: 0x{:02x}", self.code(), opcode),
|
||||
Error::IoError { source } =>
|
||||
write!(f, "E{}: {}", E_IO_ERROR, source),
|
||||
Error::DiscError { detail } =>
|
||||
write!(f, "E{}: {}", E_DISC_ERROR, detail),
|
||||
Error::AacsError { detail } =>
|
||||
write!(f, "E{}: {}", E_AACS_ERROR, detail),
|
||||
write!(f, "E{}: {}", self.code(), source),
|
||||
Error::DiscRead { sector } =>
|
||||
write!(f, "E{}: {}", self.code(), sector),
|
||||
Error::UdfNotFound { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DiscTitleRange { index, count } =>
|
||||
write!(f, "E{}: {}/{}", self.code(), index, count),
|
||||
Error::KeydbConnect { host } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_CONNECT, host),
|
||||
write!(f, "E{}: {}", self.code(), host),
|
||||
Error::KeydbHttp { status } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_HTTP, status),
|
||||
Error::KeydbInvalid =>
|
||||
write!(f, "E{}", E_KEYDB_INVALID),
|
||||
write!(f, "E{}: {}", self.code(), status),
|
||||
Error::KeydbWrite { path } =>
|
||||
write!(f, "E{}: {}", E_KEYDB_WRITE, path),
|
||||
Error::KeydbParse =>
|
||||
write!(f, "E{}", E_KEYDB_PARSE),
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
Error::KeydbLoad { path } =>
|
||||
write!(f, "E{}: {}", self.code(), path),
|
||||
// Simple codes — no extra data
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Event system for progress and status reporting.
|
||||
//!
|
||||
//! The lib fires events during operations like rip().
|
||||
//! The app registers a callback to receive them.
|
||||
//! No display logic, no English text — just data.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! disc.rip(&mut session, 0, output, |event| {
|
||||
//! match event.kind {
|
||||
//! EventKind::BytesRead { bytes, total } => update_progress(bytes, total),
|
||||
//! EventKind::ReadError { sector, .. } => log_error(sector),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//! ```
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// An event fired by the lib during operations.
|
||||
#[derive(Debug)]
|
||||
pub struct Event {
|
||||
pub kind: EventKind,
|
||||
}
|
||||
|
||||
/// Types of events the lib can fire.
|
||||
#[derive(Debug)]
|
||||
pub enum EventKind {
|
||||
/// Bytes successfully read and written to output.
|
||||
BytesRead {
|
||||
/// Bytes written so far.
|
||||
bytes: u64,
|
||||
/// Total bytes expected (0 if unknown).
|
||||
total: u64,
|
||||
},
|
||||
|
||||
/// A read error occurred. The lib will retry automatically.
|
||||
ReadError {
|
||||
/// Sector that failed.
|
||||
sector: u64,
|
||||
/// Error code.
|
||||
error: Error,
|
||||
},
|
||||
|
||||
/// Retrying a failed read.
|
||||
Retry {
|
||||
/// Current attempt number (1-based).
|
||||
attempt: u32,
|
||||
},
|
||||
|
||||
/// Drive speed changed (error recovery or restoration).
|
||||
SpeedChange {
|
||||
/// New speed in KB/s (0xFFFF = max).
|
||||
speed_kbs: u16,
|
||||
},
|
||||
|
||||
/// Starting a new disc extent.
|
||||
ExtentStart {
|
||||
/// Extent index (0-based).
|
||||
index: usize,
|
||||
/// First sector of extent.
|
||||
start_sector: u64,
|
||||
/// Number of sectors in extent.
|
||||
sector_count: u64,
|
||||
},
|
||||
|
||||
/// Operation complete.
|
||||
Complete {
|
||||
/// Total bytes written.
|
||||
bytes: u64,
|
||||
/// Total read errors encountered.
|
||||
errors: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A no-op event handler. Ignores all events.
|
||||
pub fn ignore(_event: Event) {}
|
||||
@@ -44,6 +44,9 @@ pub struct DriveId {
|
||||
|
||||
/// Raw 96-byte INQUIRY response for additional parsing if needed.
|
||||
pub raw_inquiry: Vec<u8>,
|
||||
|
||||
/// Raw GET CONFIGURATION Feature 010Ch response bytes.
|
||||
pub raw_gc_010c: Vec<u8>,
|
||||
}
|
||||
|
||||
impl DriveId {
|
||||
@@ -84,6 +87,7 @@ impl DriveId {
|
||||
firmware_date,
|
||||
serial_number,
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: gc[..result.bytes_transferred].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,6 +102,7 @@ impl DriveId {
|
||||
firmware_date: firmware_date.to_string(),
|
||||
serial_number: String::new(),
|
||||
raw_inquiry: inquiry.to_vec(),
|
||||
raw_gc_010c: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-342
@@ -1,342 +0,0 @@
|
||||
//! BD-J JAR parser — extract audio/subtitle track labels from disc menus.
|
||||
//!
|
||||
//! Blu-ray discs with BD-J menus store the menu application as Java JAR files
|
||||
//! in BDMV/JAR/. These contain .class files with string constants that label
|
||||
//! audio and subtitle tracks.
|
||||
//!
|
||||
//! Multiple label formats exist across studios:
|
||||
//! - Label format: "eng_MLP_", "fra_AudioStream3" (Warner UHD, e.g. Barbie, Dune)
|
||||
//! - TextField format: "TextField,Audio1,English Dolby Atmos,..." (A24/Lionsgate, e.g. Civil War)
|
||||
//!
|
||||
//! Labels match to streams by STN index — the Nth audio label in the JAR
|
||||
//! corresponds to the Nth audio stream in the MPLS STN table. This is defined
|
||||
//! by the BD-J API where stream numbers = STN indices (1-based).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// Labels extracted from a BD-J JAR file.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct JarLabels {
|
||||
/// Audio track labels in STN order
|
||||
pub audio: Vec<TrackLabel>,
|
||||
/// Subtitle track labels in STN order
|
||||
pub subtitle: Vec<TrackLabel>,
|
||||
/// Playlist purpose labels (MAIN_FEATURE, FORCED_TRAILER, etc.)
|
||||
pub playlists: Vec<String>,
|
||||
}
|
||||
|
||||
/// A parsed track label from the JAR.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrackLabel {
|
||||
/// Human-readable description (e.g. "English Dolby Atmos", "TrueHD", "Descriptive Audio (US)")
|
||||
pub description: String,
|
||||
/// ISO 639-2 language code if available (e.g. "eng", "fra")
|
||||
pub language: String,
|
||||
/// Codec hint if available (e.g. "MLP"=TrueHD, "AC3"=DD, "DTS")
|
||||
pub codec_hint: String,
|
||||
/// The raw string from the class file
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
/// Extract track labels from a JAR file (raw ZIP bytes).
|
||||
///
|
||||
/// Tries multiple format parsers. Returns None if no labels found.
|
||||
pub fn extract_labels(jar_data: &[u8]) -> Option<JarLabels> {
|
||||
let strings = extract_all_jar_strings(jar_data)?;
|
||||
|
||||
// Try each format parser in order
|
||||
try_textfield_format(&strings)
|
||||
.or_else(|| try_label_format(&strings))
|
||||
.or_else(|| try_playlist_only(&strings))
|
||||
}
|
||||
|
||||
// ── Format 1: TextField (A24/Lionsgate) ─────────────────────────────────────
|
||||
//
|
||||
// Pattern: "TextField,Audio{N},{description},..."
|
||||
// "TextField,Subtitle{N},{description},..."
|
||||
// Found in: Civil War, and similar A24/Lionsgate discs
|
||||
|
||||
fn try_textfield_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio: Vec<(u32, String, String)> = Vec::new(); // (index, description, raw)
|
||||
let mut subtitle: Vec<(u32, String, String)> = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if s.starts_with("TextField,Audio") {
|
||||
// TextField,Audio{N},{description},...
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1]; // "Audio1", "Audio2", etc.
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Audio") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.is_empty() {
|
||||
audio.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if s.starts_with("TextField,Subtitle") {
|
||||
let parts: Vec<&str> = s.splitn(4, ',').collect();
|
||||
if parts.len() >= 3 {
|
||||
let key = parts[1];
|
||||
let desc = parts[2].to_string();
|
||||
if let Some(num_str) = key.strip_prefix("Subtitle") {
|
||||
if let Ok(idx) = num_str.parse::<u32>() {
|
||||
if !desc.eq_ignore_ascii_case("None") && !desc.is_empty() {
|
||||
subtitle.push((idx, desc, s.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by index to ensure STN order
|
||||
audio.sort_by_key(|a| a.0);
|
||||
subtitle.sort_by_key(|s| s.0);
|
||||
|
||||
Some(JarLabels {
|
||||
audio: audio.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
subtitle: subtitle.into_iter().map(|(_, desc, raw)| TrackLabel {
|
||||
description: desc, language: String::new(), codec_hint: String::new(), raw,
|
||||
}).collect(),
|
||||
playlists,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 2: Label strings (Warner UHD) ────────────────────────────────────
|
||||
//
|
||||
// Pattern: "eng_MLP_", "fra_AudioStream3", "dan_PGStream4"
|
||||
// Found in: Barbie, Dune Part Two, and similar Warner UHD discs
|
||||
|
||||
fn try_label_format(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut audio = Vec::new();
|
||||
let mut subtitle = Vec::new();
|
||||
let mut playlists = Vec::new();
|
||||
|
||||
for s in strings {
|
||||
if let Some(label) = parse_label_string(s) {
|
||||
if label.is_audio && !audio.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
audio.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
} else if label.is_subtitle && !subtitle.iter().any(|a: &TrackLabel| a.raw == label.raw) {
|
||||
subtitle.push(TrackLabel {
|
||||
description: label.description, language: label.language,
|
||||
codec_hint: label.codec_hint, raw: label.raw,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
|
||||
if audio.is_empty() && subtitle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(JarLabels { audio, subtitle, playlists })
|
||||
}
|
||||
|
||||
struct ParsedLabel {
|
||||
description: String,
|
||||
language: String,
|
||||
codec_hint: String,
|
||||
raw: String,
|
||||
is_audio: bool,
|
||||
is_subtitle: bool,
|
||||
}
|
||||
|
||||
fn parse_label_string(s: &str) -> Option<ParsedLabel> {
|
||||
let clean = s.trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.splitn(3, '_').collect();
|
||||
if parts.len() < 2 { return None; }
|
||||
|
||||
let language = parts[0];
|
||||
if language.len() < 2 || language.len() > 3 || !language.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hint = parts[1];
|
||||
let variant = if parts.len() > 2 { parts[2] } else { "" };
|
||||
|
||||
// Only set description when the JAR adds info beyond what MPLS provides.
|
||||
// Codec (MLP, AC3, DTS) is already known from the stream. Don't make up labels.
|
||||
let (description, codec_hint, is_audio, is_subtitle) = match hint {
|
||||
"MLP" => (String::new(), "MLP".to_string(), true, false),
|
||||
"AC3" => (String::new(), "AC3".to_string(), true, false),
|
||||
"DTS" => (String::new(), "DTS".to_string(), true, false),
|
||||
"LPCM" => (String::new(), "LPCM".to_string(), true, false),
|
||||
"ADES" => {
|
||||
// Descriptive Audio — real value-add, can't be derived from MPLS
|
||||
let d = if variant.is_empty() { "Descriptive Audio".to_string() }
|
||||
else { format!("Descriptive Audio ({})", variant) };
|
||||
(d, "ADES".to_string(), true, false)
|
||||
}
|
||||
h if h.starts_with("AudioStream") => (String::new(), String::new(), true, false),
|
||||
h if h.starts_with("PGStream") => (String::new(), String::new(), false, true),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ParsedLabel {
|
||||
description, language: language.to_string(), codec_hint,
|
||||
raw: s.to_string(), is_audio, is_subtitle,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Format 3: Playlist-only ─────────────────────────────────────────────────
|
||||
//
|
||||
// Some JARs have no track labels but do have playlist purpose markers.
|
||||
|
||||
fn try_playlist_only(strings: &[String]) -> Option<JarLabels> {
|
||||
let mut playlists = Vec::new();
|
||||
for s in strings {
|
||||
collect_playlist_label(s, &mut playlists);
|
||||
}
|
||||
if playlists.is_empty() { return None; }
|
||||
Some(JarLabels { audio: Vec::new(), subtitle: Vec::new(), playlists })
|
||||
}
|
||||
|
||||
// ── Common helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
fn collect_playlist_label(s: &str, playlists: &mut Vec<String>) {
|
||||
if matches!(s.as_ref(),
|
||||
"MAIN_FEATURE" | "MAIN_FEATURE_INTRO" |
|
||||
"FORCED_TRAILER" | "INTL_FORCED_TRAILER" |
|
||||
"commentary_extras" | "extras"
|
||||
) {
|
||||
if !playlists.iter().any(|p| p == s) {
|
||||
playlists.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract all UTF-8 string constants from all .class files in a JAR.
|
||||
fn extract_all_jar_strings(jar_data: &[u8]) -> Option<Vec<String>> {
|
||||
let cursor = std::io::Cursor::new(jar_data);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut all_strings = Vec::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).ok()?;
|
||||
if !file.name().ends_with(".class") { continue; }
|
||||
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).ok()?;
|
||||
|
||||
extract_class_strings(&data, &mut all_strings);
|
||||
}
|
||||
|
||||
if all_strings.is_empty() { return None; }
|
||||
Some(all_strings)
|
||||
}
|
||||
|
||||
/// Extract UTF-8 string constants from a Java .class file's constant pool.
|
||||
fn extract_class_strings(data: &[u8], out: &mut Vec<String>) {
|
||||
if data.len() < 10 || &data[0..4] != &[0xCA, 0xFE, 0xBA, 0xBE] {
|
||||
return;
|
||||
}
|
||||
|
||||
let cp_count = ((data[8] as u16) << 8 | data[9] as u16) as usize;
|
||||
let mut pos = 10;
|
||||
let mut entry = 1;
|
||||
|
||||
while entry < cp_count && pos < data.len() {
|
||||
let tag = data[pos];
|
||||
pos += 1;
|
||||
|
||||
match tag {
|
||||
// CONSTANT_Utf8
|
||||
1 => {
|
||||
if pos + 2 > data.len() { break; }
|
||||
let len = ((data[pos] as usize) << 8) | data[pos + 1] as usize;
|
||||
pos += 2;
|
||||
if pos + len > data.len() { break; }
|
||||
|
||||
if let Ok(s) = std::str::from_utf8(&data[pos..pos + len]) {
|
||||
if s.len() >= 3 && s.len() <= 500 {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
3 | 4 => { pos += 4; }
|
||||
5 | 6 => { pos += 8; entry += 1; }
|
||||
7 | 8 | 16 => { pos += 2; }
|
||||
9 | 10 | 11 | 12 | 18 => { pos += 4; }
|
||||
15 => { pos += 3; }
|
||||
_ => { break; }
|
||||
}
|
||||
entry += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_textfield_audio() {
|
||||
let strings = vec![
|
||||
"TextField,Audio1,English Dolby Atmos,Font,296,763,275,25,left".to_string(),
|
||||
"TextField,Audio2,English Descriptive Audio,Font,296,803,275,25,left".to_string(),
|
||||
"TextField,Audio3,Spanish 5.1 Dolby Digital,Font,296,843,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, "English Dolby Atmos");
|
||||
assert_eq!(labels.audio[1].description, "English Descriptive Audio");
|
||||
assert_eq!(labels.audio[2].description, "Spanish 5.1 Dolby Digital");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_textfield_subtitle_skips_none() {
|
||||
let strings = vec![
|
||||
"TextField,Subtitle0,None,Font,1312,843,275,25,left".to_string(),
|
||||
"TextField,Subtitle1,English SDH,Font,1312,763,275,25,left".to_string(),
|
||||
"TextField,Subtitle2,Spanish,Font,1312,803,275,25,left".to_string(),
|
||||
];
|
||||
let labels = try_textfield_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 2);
|
||||
assert_eq!(labels.subtitle[0].description, "English SDH");
|
||||
assert_eq!(labels.subtitle[1].description, "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_audio() {
|
||||
let strings = vec![
|
||||
"eng_MLP_".to_string(),
|
||||
"eng_ADES_US_".to_string(),
|
||||
"fra_AudioStream3".to_string(),
|
||||
];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.audio.len(), 3);
|
||||
assert_eq!(labels.audio[0].description, ""); // MLP — codec already known from stream
|
||||
assert_eq!(labels.audio[1].description, "Descriptive Audio (US)"); // ADES — real value-add
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_label_format_subtitle() {
|
||||
let strings = vec!["dan_PGStream4".to_string()];
|
||||
let labels = try_label_format(&strings).unwrap();
|
||||
assert_eq!(labels.subtitle.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_labels() {
|
||||
let strings = vec!["java/lang/Object".to_string(), "toString".to_string()];
|
||||
assert!(try_textfield_format(&strings).is_none());
|
||||
assert!(try_label_format(&strings).is_none());
|
||||
}
|
||||
}
|
||||
+22
-18
@@ -1,4 +1,4 @@
|
||||
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//! libfreemkv -- Open source optical drive library for 4K UHD / Blu-ray / DVD.
|
||||
//!
|
||||
//! Handles drive access, disc structure parsing, AACS decryption, and raw
|
||||
//! sector reading. 206 bundled drive profiles. No external files needed.
|
||||
@@ -14,7 +14,7 @@
|
||||
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
|
||||
//!
|
||||
//! for title in &disc.titles {
|
||||
//! println!("{} — {} streams", title.duration_display(), title.streams.len());
|
||||
//! println!("{} -- {} streams", title.duration_display(), title.streams.len());
|
||||
//! }
|
||||
//!
|
||||
//! // Read content (decrypted automatically if AACS keys available)
|
||||
@@ -27,21 +27,21 @@
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! DriveSession — open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile — per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId — INQUIRY + GET_CONFIG identification
|
||||
//! DriveSession -- open, identify, unlock, read sectors
|
||||
//! ├── ScsiTransport -- SG_IO (Linux), IOKit (macOS)
|
||||
//! ├── DriveProfile -- per-drive unlock parameters (206 bundled)
|
||||
//! ├── DriveId -- INQUIRY + GET_CONFIG identification
|
||||
//! └── Platform
|
||||
//! └── Mt1959 — MediaTek unlock/read (Renesas planned)
|
||||
//! └── Mt1959 -- MediaTek unlock/read (Renesas planned)
|
||||
//!
|
||||
//! Disc — scan titles, streams, AACS state
|
||||
//! ├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||
//! ├── MPLS parser — playlists → titles + clips + STN streams
|
||||
//! ├── CLPI parser — clip info → EP map → sector extents
|
||||
//! ├── JAR parser — BD-J audio track labels
|
||||
//! └── AACS — encryption: key resolution + content decrypt
|
||||
//! ├── aacs — KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake — SCSI auth, ECDH, bus key
|
||||
//! Disc -- scan titles, streams, AACS state
|
||||
//! ├── UDF reader -- Blu-ray UDF 2.50 with metadata partitions
|
||||
//! ├── MPLS parser -- playlists → titles + clips + STN streams
|
||||
//! ├── CLPI parser -- clip info → EP map → sector extents
|
||||
//! ├── JAR parser -- BD-J audio track labels
|
||||
//! └── AACS -- encryption: key resolution + content decrypt
|
||||
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
|
||||
//! └── handshake -- SCSI auth, ECDH, bus key
|
||||
//! ```
|
||||
//!
|
||||
//! # AACS Encryption
|
||||
@@ -55,7 +55,7 @@
|
||||
//! # Error Codes
|
||||
//!
|
||||
//! All errors are structured with numeric codes. No user-facing English
|
||||
//! text — applications format their own messages.
|
||||
//! text -- applications format their own messages.
|
||||
//!
|
||||
//! | Range | Category |
|
||||
//! |-------|----------|
|
||||
@@ -81,14 +81,18 @@ pub mod disc;
|
||||
pub mod aacs;
|
||||
pub mod labels;
|
||||
pub mod keydb;
|
||||
pub mod event;
|
||||
pub mod mux;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use event::{Event, EventKind};
|
||||
pub use drive::{DriveSession, find_drive, find_drives, resolve_device};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::{DriveProfile, Chipset};
|
||||
pub use platform::{Platform, DriveStatus};
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use DriveSession, not Platform directly
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use speed::DriveSpeed;
|
||||
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
|
||||
Codec, HdrFormat, ColorSpace,
|
||||
Extent, ContentReader, AacsState, KeySource, ScanOptions};
|
||||
pub use mux::MkvStream;
|
||||
|
||||
+49
-8
@@ -62,17 +62,17 @@ pub struct StreamEntry {
|
||||
/// 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() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
if &data[0..4] != b"MPLS" {
|
||||
return Err(Error::DiscError { detail: "not an MPLS file".into() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let version = String::from_utf8_lossy(&data[4..8]).to_string();
|
||||
let playlist_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
|
||||
if playlist_start + 10 > data.len() {
|
||||
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
|
||||
return Err(Error::MplsParse);
|
||||
}
|
||||
|
||||
let pl = &data[playlist_start..];
|
||||
@@ -111,7 +111,7 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
let n_ig = item[STN_OFFSET + 7] as usize;
|
||||
let n_sec_audio = item[STN_OFFSET + 8] as usize;
|
||||
let n_sec_video = item[STN_OFFSET + 9] as usize;
|
||||
let _n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_pip_pg = item[STN_OFFSET + 10] as usize;
|
||||
let n_dv = item[STN_OFFSET + 11] as usize;
|
||||
|
||||
let mut spos = STN_OFFSET + 16;
|
||||
@@ -173,6 +173,18 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Secondary PG (PiP subtitles) — must consume to keep spos aligned
|
||||
for _ in 0..n_pip_pg {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 3) {
|
||||
entry.secondary = true;
|
||||
streams.push(entry);
|
||||
// Skip reference data: num_refs(1) + reserved(1) + refs + padding
|
||||
if next < item.len() {
|
||||
let n_refs = item[next] as usize;
|
||||
spos = next + 2 + n_refs + (n_refs % 2);
|
||||
} else { spos = next; }
|
||||
} else { break; }
|
||||
}
|
||||
// Dolby Vision enhancement layer
|
||||
for _ in 0..n_dv {
|
||||
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
|
||||
@@ -227,6 +239,7 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
let sa = &item[se_end + 1..se_end + 1 + sa_len];
|
||||
let coding_type = sa[0];
|
||||
|
||||
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut audio_format = 0u8;
|
||||
@@ -249,6 +262,29 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
}
|
||||
2 => {
|
||||
// Audio: coding_type(1) + format_rate(1) + language(3)
|
||||
// Exception: PGS (0x90/0x91) in audio slot uses PG layout: coding_type(1) + language(3)
|
||||
if coding_type == 0x90 || coding_type == 0x91 {
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
} else {
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if sa.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
// Secondary audio: same as primary audio
|
||||
if sa.len() >= 2 {
|
||||
audio_format = (sa[1] >> 4) & 0x0F;
|
||||
audio_rate = sa[1] & 0x0F;
|
||||
@@ -257,10 +293,15 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
language = String::from_utf8_lossy(&sa[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
3 | 4 => {
|
||||
// PG/IG: coding_type(1) + language(3)
|
||||
if sa.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sa[1..4]).to_string();
|
||||
6 | 7 => {
|
||||
// Secondary video: same as primary video
|
||||
if sa.len() >= 2 {
|
||||
video_format = (sa[1] >> 4) & 0x0F;
|
||||
video_rate = sa[1] & 0x0F;
|
||||
}
|
||||
if coding_type == 0x24 && sa.len() > 2 {
|
||||
dynamic_range = (sa[2] >> 4) & 0x0F;
|
||||
color_space_val = sa[2] & 0x0F;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! AC3 (Dolby Digital) / EAC3 (Dolby Digital Plus) frame parser.
|
||||
//!
|
||||
//! AC3 frames are self-contained and always start with syncword 0x0B77.
|
||||
//! Each PES packet typically contains exactly one AC3 frame.
|
||||
//! All AC3 frames are effectively keyframes (no inter-frame dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct Ac3Parser;
|
||||
|
||||
impl Ac3Parser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Ac3Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// AC3: each PES = one frame, always a keyframe
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// AC3 doesn't need codecPrivate in MKV
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! DTS / DTS-HD elementary stream parser.
|
||||
//!
|
||||
//! DTS core syncword: 0x7FFE8001 (32 bits).
|
||||
//! DTS-HD MA/HRA extension follows the core frame.
|
||||
//! All frames are keyframes (no inter-frame dependencies).
|
||||
//! Each PES packet = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct DtsParser;
|
||||
|
||||
impl DtsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for DtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! H.264 (AVC) elementary stream parser.
|
||||
//!
|
||||
//! Extracts SPS and PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
const NAL_SLICE_IDR: u8 = 5;
|
||||
const NAL_SPS: u8 = 7;
|
||||
const NAL_PPS: u8 = 8;
|
||||
const NAL_AUD: u8 = 9;
|
||||
|
||||
pub struct H264Parser {
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl H264Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for H264Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Scan NAL units for SPS, PPS, and IDR detection
|
||||
let mut keyframe = false;
|
||||
let mut frame_data = Vec::new();
|
||||
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
|
||||
match nal_type {
|
||||
NAL_SPS => {
|
||||
self.sps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_PPS => {
|
||||
self.pps = Some(nal.to_vec());
|
||||
}
|
||||
NAL_SLICE_IDR => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B (start code prefixed) to length-prefixed NALUs.
|
||||
// MKV with AVCDecoderConfigurationRecord expects 4-byte length prefix per NALU.
|
||||
// Skip SPS/PPS/AUD NALUs — they're in codecPrivate, not in frame data.
|
||||
for nal in NalIterator::new(&pes.data) {
|
||||
let nal_type = nal[0] & 0x1F;
|
||||
// Skip parameter sets and access unit delimiters
|
||||
if nal_type == NAL_SPS || nal_type == NAL_PPS || nal_type == NAL_AUD {
|
||||
continue;
|
||||
}
|
||||
// 4-byte big-endian length prefix
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// Build AVCDecoderConfigurationRecord from SPS + PPS
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// AVCDecoderConfigurationRecord (ISO 14496-15):
|
||||
// configurationVersion = 1
|
||||
// AVCProfileIndication = SPS[1]
|
||||
// profile_compatibility = SPS[2]
|
||||
// AVCLevelIndication = SPS[3]
|
||||
// lengthSizeMinusOne = 3 (4-byte length prefix)
|
||||
// numOfSequenceParameterSets = 1
|
||||
// sequenceParameterSetLength = sps.len()
|
||||
// sequenceParameterSetNALUnit = sps
|
||||
// numOfPictureParameterSets = 1
|
||||
// pictureParameterSetLength = pps.len()
|
||||
// pictureParameterSetNALUnit = pps
|
||||
|
||||
let mut record = Vec::new();
|
||||
record.push(1); // configurationVersion
|
||||
record.push(sps[1]); // profile
|
||||
record.push(sps[2]); // compatibility
|
||||
record.push(sps[3]); // level
|
||||
record.push(0xFF); // 6 bits reserved (111111) + 2 bits lengthSizeMinusOne (11 = 3)
|
||||
record.push(0xE1); // 3 bits reserved (111) + 5 bits numSPS (1)
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
record.push(1); // numPPS
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterator over NAL units in Annex B byte stream.
|
||||
/// Finds start codes (00 00 01 or 00 00 00 01) and yields the data between them.
|
||||
struct NalIterator<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> NalIterator<'a> {
|
||||
fn new(data: &'a [u8]) -> Self {
|
||||
// Skip to first start code
|
||||
let pos = find_start_code(data, 0).unwrap_or(data.len());
|
||||
Self { data, pos }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for NalIterator<'a> {
|
||||
type Item = &'a [u8];
|
||||
|
||||
fn next(&mut self) -> Option<&'a [u8]> {
|
||||
if self.pos >= self.data.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip the start code at current position
|
||||
let nal_start = skip_start_code(self.data, self.pos)?;
|
||||
|
||||
// Find next start code (or end of data)
|
||||
let nal_end = find_start_code(self.data, nal_start).unwrap_or(self.data.len());
|
||||
|
||||
// Remove trailing zeros (part of next start code's zero prefix)
|
||||
let mut end = nal_end;
|
||||
while end > nal_start && self.data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
self.pos = nal_end;
|
||||
|
||||
if end > nal_start {
|
||||
Some(&self.data[nal_start..end])
|
||||
} else {
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
pub fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
return None;
|
||||
}
|
||||
for i in from..data.len() - 2 {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Skip past the start code at position `pos`, returning the first byte after it.
|
||||
pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
|
||||
if pos + 2 >= data.len() {
|
||||
return None;
|
||||
}
|
||||
if data[pos] == 0x00 && data[pos + 1] == 0x00 {
|
||||
if pos + 3 < data.len() && data[pos + 2] == 0x00 && data[pos + 3] == 0x01 {
|
||||
return Some(pos + 4); // 4-byte start code
|
||||
}
|
||||
if data[pos + 2] == 0x01 {
|
||||
return Some(pos + 3); // 3-byte start code
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! HEVC (H.265) elementary stream parser.
|
||||
//!
|
||||
//! Extracts VPS, SPS, PPS NAL units for MKV codecPrivate.
|
||||
//! Detects keyframes (IRAP pictures: IDR, CRA, BLA).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
|
||||
// HEVC NAL unit types
|
||||
const NAL_VPS: u8 = 32;
|
||||
const NAL_SPS: u8 = 33;
|
||||
const NAL_PPS: u8 = 34;
|
||||
const NAL_AUD: u8 = 35;
|
||||
// IRAP types (keyframes): BLA, IDR, CRA
|
||||
const NAL_BLA_W_LP: u8 = 16;
|
||||
const NAL_RSV_IRAP_VCL23: u8 = 23;
|
||||
|
||||
pub struct HevcParser {
|
||||
vps: Option<Vec<u8>>,
|
||||
sps: Option<Vec<u8>>,
|
||||
pps: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl HevcParser {
|
||||
pub fn new() -> Self {
|
||||
Self { vps: None, sps: None, pps: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for HevcParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
|
||||
// Scan NAL units
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
let next = find_start_code(data, nal_start).unwrap_or(data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < data.len() {
|
||||
// HEVC NAL header: 2 bytes. Type is bits 1-6 of first byte.
|
||||
let nal_type = (data[nal_start] >> 1) & 0x3F;
|
||||
|
||||
match nal_type {
|
||||
NAL_VPS => self.vps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_SPS => self.sps = Some(data[nal_start..end].to_vec()),
|
||||
NAL_PPS => self.pps = Some(data[nal_start..end].to_vec()),
|
||||
t if t >= NAL_BLA_W_LP && t <= NAL_RSV_IRAP_VCL23 => {
|
||||
keyframe = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Annex B to length-prefixed NALUs.
|
||||
// Skip VPS/SPS/PPS/AUD — they're in codecPrivate.
|
||||
let mut frame_data = Vec::new();
|
||||
let mut pos = 0;
|
||||
while let Some(sc_pos) = find_start_code(&pes.data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(&pes.data, sc_pos) {
|
||||
let next = find_start_code(&pes.data, nal_start).unwrap_or(pes.data.len());
|
||||
let mut end = next;
|
||||
while end > nal_start && pes.data[end - 1] == 0x00 { end -= 1; }
|
||||
|
||||
if nal_start < pes.data.len() {
|
||||
let nal_type = (pes.data[nal_start] >> 1) & 0x3F;
|
||||
// Skip parameter sets and AUD
|
||||
if nal_type != NAL_VPS && nal_type != NAL_SPS && nal_type != NAL_PPS && nal_type != NAL_AUD {
|
||||
let nal = &pes.data[nal_start..end];
|
||||
let len = nal.len() as u32;
|
||||
frame_data.extend_from_slice(&len.to_be_bytes());
|
||||
frame_data.extend_from_slice(nal);
|
||||
}
|
||||
}
|
||||
pos = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if frame_data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: frame_data,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// HEVCDecoderConfigurationRecord (ISO 14496-15)
|
||||
let vps = self.vps.as_ref()?;
|
||||
let sps = self.sps.as_ref()?;
|
||||
let pps = self.pps.as_ref()?;
|
||||
|
||||
// Simplified: store as arrays in Annex B format
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
let mut record = Vec::new();
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
} else {
|
||||
record.push(0);
|
||||
}
|
||||
// general_profile_compatibility_flags (4 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0]);
|
||||
// general_constraint_indicator_flags (6 bytes)
|
||||
record.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
// general_level_idc
|
||||
record.push(if sps.len() > 12 { sps[12] } else { 0 });
|
||||
// min_spatial_segmentation_idc (4 + 12 bits)
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// avgFrameRate
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
// VPS array
|
||||
record.push(0x20 | (NAL_VPS & 0x3F)); // array_completeness + NAL type
|
||||
record.extend_from_slice(&[0, 1]); // numNalus = 1
|
||||
record.push((vps.len() >> 8) as u8);
|
||||
record.push(vps.len() as u8);
|
||||
record.extend_from_slice(vps);
|
||||
|
||||
// SPS array
|
||||
record.push(0x20 | (NAL_SPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((sps.len() >> 8) as u8);
|
||||
record.push(sps.len() as u8);
|
||||
record.extend_from_slice(sps);
|
||||
|
||||
// PPS array
|
||||
record.push(0x20 | (NAL_PPS & 0x3F));
|
||||
record.extend_from_slice(&[0, 1]);
|
||||
record.push((pps.len() >> 8) as u8);
|
||||
record.push(pps.len() as u8);
|
||||
record.extend_from_slice(pps);
|
||||
|
||||
Some(record)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Elementary stream codec parsers.
|
||||
//!
|
||||
//! Each parser takes PES packets and produces frames suitable for MKV muxing.
|
||||
//! Responsibilities:
|
||||
//! - Find frame boundaries
|
||||
//! - Extract codec initialization data (SPS/PPS, etc.)
|
||||
//! - Determine keyframe status
|
||||
//! - Convert PTS from 90kHz to nanoseconds
|
||||
|
||||
pub mod ac3;
|
||||
pub mod h264;
|
||||
pub mod hevc;
|
||||
pub mod vc1;
|
||||
pub mod dts;
|
||||
pub mod truehd;
|
||||
pub mod pgs;
|
||||
|
||||
use crate::disc::Codec;
|
||||
use super::ts::PesPacket;
|
||||
|
||||
/// A single frame ready for MKV muxing.
|
||||
pub struct Frame {
|
||||
/// Presentation timestamp in nanoseconds.
|
||||
pub pts_ns: i64,
|
||||
/// Whether this is a keyframe (used for cue points).
|
||||
pub keyframe: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Convert 90kHz PTS to nanoseconds.
|
||||
pub fn pts_to_ns(pts: i64) -> i64 {
|
||||
pts * 100_000 / 9
|
||||
}
|
||||
|
||||
/// Trait for codec-specific elementary stream parsers.
|
||||
pub trait CodecParser: Send {
|
||||
/// Parse a PES packet into zero or more frames.
|
||||
/// Most codecs: one PES = one frame.
|
||||
/// Some (TrueHD): multiple access units per PES.
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame>;
|
||||
|
||||
/// Get codec initialization data (e.g., SPS+PPS for H.264).
|
||||
/// Returns None until enough data has been seen.
|
||||
fn codec_private(&self) -> Option<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Passthrough parser — treats each PES as one frame, no parsing.
|
||||
/// Used for codecs where PES = frame (AC3, DTS, PGS).
|
||||
pub struct PassthroughParser {
|
||||
keyframe: bool,
|
||||
}
|
||||
|
||||
impl PassthroughParser {
|
||||
pub fn new(always_keyframe: bool) -> Self {
|
||||
Self { keyframe: always_keyframe }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for PassthroughParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate parser for a codec.
|
||||
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
Codec::Vc1 => Box::new(vc1::Vc1Parser::new()),
|
||||
Codec::Ac3 | Codec::Ac3Plus => Box::new(ac3::Ac3Parser::new()),
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
Codec::Lpcm => Box::new(PassthroughParser::new(true)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! HDMV PGS (Presentation Graphics Stream) subtitle parser.
|
||||
//!
|
||||
//! PGS segments: PCS, WDS, PDS, ODS, END.
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
|
||||
impl PgsParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for PgsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Dolby TrueHD / Atmos elementary stream parser.
|
||||
//!
|
||||
//! TrueHD major sync: 0xF8726FBA at a 4-byte aligned position.
|
||||
//! Access units consist of a major sync followed by minor syncs.
|
||||
//! An embedded AC3 core is in substream 0 for backward compatibility.
|
||||
//! All access units are keyframes.
|
||||
//! Each PES packet = one access unit.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct TrueHdParser;
|
||||
|
||||
impl TrueHdParser {
|
||||
pub fn new() -> Self { Self }
|
||||
}
|
||||
|
||||
impl CodecParser for TrueHdParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame { pts_ns, keyframe: true, data: pes.data.clone() }]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> { None }
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! VC-1 (SMPTE 421M) elementary stream parser.
|
||||
//!
|
||||
//! VC-1 uses start codes similar to MPEG-2.
|
||||
//! Sequence header (0x0F) contains codec initialization data.
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SC_SEQUENCE_HEADER: u8 = 0x0F;
|
||||
const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
const SC_FRAME: u8 = 0x0D;
|
||||
|
||||
pub struct Vc1Parser {
|
||||
seq_header: Option<Vec<u8>>,
|
||||
entry_point: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Vc1Parser {
|
||||
pub fn new() -> Self {
|
||||
Self { seq_header: None, entry_point: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for Vc1Parser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
let mut keyframe = false;
|
||||
|
||||
// Scan for start codes (00 00 01 XX)
|
||||
let data = &pes.data;
|
||||
let mut i = 0;
|
||||
while i + 3 < data.len() {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
let sc_type = data[i + 3];
|
||||
match sc_type {
|
||||
SC_SEQUENCE_HEADER => {
|
||||
// Capture everything from here to next start code
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.seq_header = Some(data[i..end].to_vec());
|
||||
}
|
||||
SC_ENTRY_POINT => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.entry_point = Some(data[i..end].to_vec());
|
||||
}
|
||||
SC_FRAME => {
|
||||
// First frame after sequence header + entry point is a keyframe
|
||||
if self.seq_header.is_some() && self.entry_point.is_some() {
|
||||
keyframe = true;
|
||||
}
|
||||
// Also check frame type from bitstream (bit after start code)
|
||||
if i + 4 < data.len() {
|
||||
// For Advanced profile: first 2 bits of frame data indicate type
|
||||
// But simpler: any frame preceded by seq+entry is I-frame
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 4;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
data: pes.data.clone(),
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
// MKV V_MS/VFW/FOURCC requires BITMAPINFOHEADER (40 bytes) + extra codec data.
|
||||
// The sequence header + entry point go as extra data after the header.
|
||||
let sh = self.seq_header.as_ref()?;
|
||||
let ep = self.entry_point.as_ref()?;
|
||||
|
||||
let extra_len = sh.len() + ep.len();
|
||||
let header_size: u32 = 40 + extra_len as u32;
|
||||
|
||||
let mut cp = Vec::with_capacity(header_size as usize);
|
||||
|
||||
// BITMAPINFOHEADER (40 bytes, little-endian)
|
||||
cp.extend_from_slice(&header_size.to_le_bytes()); // biSize
|
||||
cp.extend_from_slice(&1920u32.to_le_bytes()); // biWidth (updated by player)
|
||||
cp.extend_from_slice(&1080u32.to_le_bytes()); // biHeight
|
||||
cp.extend_from_slice(&1u16.to_le_bytes()); // biPlanes
|
||||
cp.extend_from_slice(&24u16.to_le_bytes()); // biBitCount
|
||||
cp.extend_from_slice(b"WVC1"); // biCompression = "WVC1" FOURCC
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biSizeImage
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biXPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biYPelsPerMeter
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrUsed
|
||||
cp.extend_from_slice(&0u32.to_le_bytes()); // biClrImportant
|
||||
|
||||
// Extra codec data: sequence header + entry point (Annex B)
|
||||
cp.extend_from_slice(sh);
|
||||
cp.extend_from_slice(ep);
|
||||
|
||||
Some(cp)
|
||||
}
|
||||
}
|
||||
|
||||
fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
|
||||
for i in from..data.len().saturating_sub(2) {
|
||||
if data[i] == 0x00 && data[i + 1] == 0x00 && data[i + 2] == 0x01 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
//! EBML (Extensible Binary Meta Language) write primitives for Matroska.
|
||||
//!
|
||||
//! EBML uses variable-length integers for element IDs and sizes.
|
||||
//! This module provides low-level writers for constructing MKV files.
|
||||
|
||||
use std::io::{self, Write, Seek, SeekFrom};
|
||||
|
||||
/// Write an EBML element ID (1-4 bytes, already encoded).
|
||||
/// Element IDs are predefined constants — we write them verbatim.
|
||||
pub fn write_id(w: &mut impl Write, id: u32) -> io::Result<()> {
|
||||
if id <= 0x7F {
|
||||
w.write_all(&[id as u8])
|
||||
} else if id <= 0x7FFF {
|
||||
w.write_all(&[(id >> 8) as u8, id as u8])
|
||||
} else if id <= 0x7F_FFFF {
|
||||
w.write_all(&[(id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
} else {
|
||||
w.write_all(&[(id >> 24) as u8, (id >> 16) as u8, (id >> 8) as u8, id as u8])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML variable-length size (1-8 bytes).
|
||||
/// Uses the EBML VINT encoding: leading bits indicate width.
|
||||
pub fn write_size(w: &mut impl Write, size: u64) -> io::Result<()> {
|
||||
if size < 0x7F {
|
||||
w.write_all(&[(size as u8) | 0x80])
|
||||
} else if size < 0x3FFF {
|
||||
w.write_all(&[((size >> 8) as u8) | 0x40, size as u8])
|
||||
} else if size < 0x1F_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 16) as u8) | 0x20,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else if size < 0x0FFF_FFFF {
|
||||
w.write_all(&[
|
||||
((size >> 24) as u8) | 0x10,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
} else {
|
||||
// 8-byte size for large elements
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(size >> 48) as u8,
|
||||
(size >> 40) as u8,
|
||||
(size >> 32) as u8,
|
||||
(size >> 24) as u8,
|
||||
(size >> 16) as u8,
|
||||
(size >> 8) as u8,
|
||||
size as u8,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an EBML "unknown size" marker (all 1s in VINT, 8 bytes).
|
||||
/// Used for the Segment element when total size isn't known upfront.
|
||||
pub fn write_unknown_size(w: &mut impl Write) -> io::Result<()> {
|
||||
w.write_all(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// Write a complete EBML unsigned integer element.
|
||||
pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
if val <= 0xFF {
|
||||
write_size(w, 1)?;
|
||||
w.write_all(&[val as u8])
|
||||
} else if val <= 0xFFFF {
|
||||
write_size(w, 2)?;
|
||||
w.write_all(&[(val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFF_FFFF {
|
||||
write_size(w, 3)?;
|
||||
w.write_all(&[(val >> 16) as u8, (val >> 8) as u8, val as u8])
|
||||
} else if val <= 0xFFFF_FFFF {
|
||||
write_size(w, 4)?;
|
||||
w.write_all(&[
|
||||
(val >> 24) as u8, (val >> 16) as u8,
|
||||
(val >> 8) as u8, val as u8,
|
||||
])
|
||||
} else {
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed integer element.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_uint(w, id, val as u64)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, 8)?;
|
||||
w.write_all(&val.to_be_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML UTF-8 string element.
|
||||
pub fn write_string(w: &mut impl Write, id: u32, val: &str) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, val.len() as u64)?;
|
||||
w.write_all(val.as_bytes())
|
||||
}
|
||||
|
||||
/// Write a complete EBML binary element.
|
||||
pub fn write_binary(w: &mut impl Write, id: u32, data: &[u8]) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
write_size(w, data.len() as u64)?;
|
||||
w.write_all(data)
|
||||
}
|
||||
|
||||
/// Start a master element: write ID + placeholder size.
|
||||
/// Returns the file offset of the size field for later fixup.
|
||||
pub fn start_master<W: Write + Seek>(w: &mut W, id: u32) -> io::Result<u64> {
|
||||
write_id(w, id)?;
|
||||
let size_pos = w.stream_position()?;
|
||||
// 8-byte size placeholder (will be overwritten by end_master)
|
||||
w.write_all(&[0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
|
||||
Ok(size_pos)
|
||||
}
|
||||
|
||||
/// End a master element: seek back and write the actual size.
|
||||
pub fn end_master<W: Write + Seek>(w: &mut W, size_pos: u64) -> io::Result<()> {
|
||||
let end_pos = w.stream_position()?;
|
||||
let data_size = end_pos - size_pos - 8; // subtract the 8-byte size field itself
|
||||
w.seek(SeekFrom::Start(size_pos))?;
|
||||
// Write as 8-byte VINT: 0x01 followed by 7 bytes of size
|
||||
w.write_all(&[
|
||||
0x01,
|
||||
(data_size >> 48) as u8,
|
||||
(data_size >> 40) as u8,
|
||||
(data_size >> 32) as u8,
|
||||
(data_size >> 24) as u8,
|
||||
(data_size >> 16) as u8,
|
||||
(data_size >> 8) as u8,
|
||||
data_size as u8,
|
||||
])?;
|
||||
w.seek(SeekFrom::Start(end_pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Matroska Element IDs
|
||||
// ============================================================
|
||||
|
||||
// EBML Header
|
||||
pub const EBML: u32 = 0x1A45DFA3;
|
||||
pub const EBML_VERSION: u32 = 0x4286;
|
||||
pub const EBML_READ_VERSION: u32 = 0x42F7;
|
||||
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
|
||||
pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3;
|
||||
pub const EBML_DOC_TYPE: u32 = 0x4282;
|
||||
pub const EBML_DOC_TYPE_VERSION: u32 = 0x4287;
|
||||
pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x18538067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2AD7B1;
|
||||
pub const DURATION: u32 = 0x4489;
|
||||
pub const MUXING_APP: u32 = 0x4D80;
|
||||
pub const WRITING_APP: u32 = 0x5741;
|
||||
pub const TITLE: u32 = 0x7BA9;
|
||||
|
||||
// Tracks
|
||||
pub const TRACKS: u32 = 0x1654AE6B;
|
||||
pub const TRACK_ENTRY: u32 = 0xAE;
|
||||
pub const TRACK_NUMBER: u32 = 0xD7;
|
||||
pub const TRACK_UID: u32 = 0x73C5;
|
||||
pub const TRACK_TYPE: u32 = 0x83;
|
||||
pub const FLAG_LACING: u32 = 0x9C;
|
||||
pub const FLAG_DEFAULT: u32 = 0x88;
|
||||
pub const FLAG_FORCED: u32 = 0x55AA;
|
||||
pub const LANGUAGE: u32 = 0x22B59C;
|
||||
pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23E383;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
pub const PIXEL_WIDTH: u32 = 0xB0;
|
||||
pub const PIXEL_HEIGHT: u32 = 0xBA;
|
||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||
pub const DISPLAY_HEIGHT: u32 = 0x54BA;
|
||||
pub const COLOUR: u32 = 0x55B0;
|
||||
pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA;
|
||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
pub const CHANNELS: u32 = 0x9F;
|
||||
pub const BIT_DEPTH: u32 = 0x6264;
|
||||
|
||||
// Cluster
|
||||
pub const CLUSTER: u32 = 0x1F43B675;
|
||||
pub const CLUSTER_TIMESTAMP: u32 = 0xE7;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
|
||||
// Cues
|
||||
pub const CUES: u32 = 0x1C53BB6B;
|
||||
pub const CUE_POINT: u32 = 0xBB;
|
||||
pub const CUE_TIME: u32 = 0xB3;
|
||||
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
|
||||
// Track types
|
||||
pub const TRACK_TYPE_VIDEO: u64 = 1;
|
||||
pub const TRACK_TYPE_AUDIO: u64 = 2;
|
||||
pub const TRACK_TYPE_SUBTITLE: u64 = 17;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_write_size() {
|
||||
let mut buf = Vec::new();
|
||||
write_size(&mut buf, 0).unwrap();
|
||||
assert_eq!(buf, [0x80]);
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 127).unwrap();
|
||||
assert_eq!(buf, [0x40, 127]); // 127 >= 0x7F, uses 2 bytes: (0>>8)|0x40, 127
|
||||
|
||||
buf.clear();
|
||||
write_size(&mut buf, 126).unwrap();
|
||||
assert_eq!(buf, [126 | 0x80]); // 126 < 0x7F, uses 1 byte
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_string() {
|
||||
let mut buf = Vec::new();
|
||||
write_string(&mut buf, 0x4282, "matroska").unwrap();
|
||||
// ID: 42 82, Size: 88 (8 bytes), Data: "matroska"
|
||||
assert_eq!(&buf[0..2], &[0x42, 0x82]);
|
||||
assert_eq!(buf[2], 0x88); // size = 8
|
||||
assert_eq!(&buf[3..], b"matroska");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_element() {
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
let pos = start_master(&mut buf, EBML).unwrap();
|
||||
write_uint(&mut buf, EBML_VERSION, 1).unwrap();
|
||||
end_master(&mut buf, pos).unwrap();
|
||||
let data = buf.into_inner();
|
||||
// EBML header: 1A 45 DF A3, then 8-byte size, then content
|
||||
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! LookaheadBuffer — generic pre-scan buffer for stream pipelines.
|
||||
//!
|
||||
//! Accumulates data up to a configurable limit. When the consumer finds
|
||||
//! what it needs, the buffer can be drained (fast path, no re-read).
|
||||
//! If the buffer fills before the consumer is satisfied, it signals
|
||||
//! overflow — the caller should discard and re-read from the source.
|
||||
//!
|
||||
//! Used by MkvStream to collect SPS/PPS before writing the MKV header.
|
||||
//! Reusable for any stream stage that needs to look ahead.
|
||||
|
||||
/// Default lookahead buffer size: 5 MB.
|
||||
pub const DEFAULT_LOOKAHEAD_SIZE: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Lookahead buffer states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LookaheadState {
|
||||
/// Still collecting data, haven't found what we need yet.
|
||||
Collecting,
|
||||
/// Found what we need, buffer has the data ready to drain.
|
||||
Ready,
|
||||
/// Buffer overflowed before finding what we need.
|
||||
/// Caller should discard buffer, finish scanning without buffering,
|
||||
/// then re-read from the source.
|
||||
Overflow,
|
||||
}
|
||||
|
||||
/// A bounded lookahead buffer.
|
||||
pub struct LookaheadBuffer {
|
||||
data: Vec<u8>,
|
||||
max_size: usize,
|
||||
state: LookaheadState,
|
||||
}
|
||||
|
||||
impl LookaheadBuffer {
|
||||
/// Create a new buffer with the given max size.
|
||||
/// Pass 0 for no buffering (always overflows immediately).
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)),
|
||||
max_size,
|
||||
state: LookaheadState::Collecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push data into the buffer. Returns the new state.
|
||||
/// If the buffer would overflow, transitions to Overflow state.
|
||||
pub fn push(&mut self, chunk: &[u8]) -> LookaheadState {
|
||||
if self.state != LookaheadState::Collecting {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
if self.data.len() + chunk.len() > self.max_size {
|
||||
self.state = LookaheadState::Overflow;
|
||||
return self.state;
|
||||
}
|
||||
|
||||
self.data.extend_from_slice(chunk);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Mark the buffer as ready — we found what we need.
|
||||
pub fn mark_ready(&mut self) {
|
||||
if self.state == LookaheadState::Collecting {
|
||||
self.state = LookaheadState::Ready;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the buffered data (only valid in Ready state).
|
||||
pub fn data(&self) -> &[u8] {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// Take ownership of the buffered data, clearing the buffer.
|
||||
pub fn drain(&mut self) -> Vec<u8> {
|
||||
self.state = LookaheadState::Collecting;
|
||||
std::mem::take(&mut self.data)
|
||||
}
|
||||
|
||||
/// Current state.
|
||||
pub fn state(&self) -> LookaheadState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// How many bytes are buffered.
|
||||
pub fn len(&self) -> usize {
|
||||
self.data.len()
|
||||
}
|
||||
|
||||
/// Is the buffer empty?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.data.is_empty()
|
||||
}
|
||||
|
||||
/// Max size this buffer can hold.
|
||||
pub fn max_size(&self) -> usize {
|
||||
self.max_size
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_basic_flow() {
|
||||
let mut buf = LookaheadBuffer::new(100);
|
||||
assert_eq!(buf.push(b"hello"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"world"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.len(), 10);
|
||||
buf.mark_ready();
|
||||
assert_eq!(buf.state(), LookaheadState::Ready);
|
||||
assert_eq!(buf.data(), b"helloworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut buf = LookaheadBuffer::new(5);
|
||||
assert_eq!(buf.push(b"abc"), LookaheadState::Collecting);
|
||||
assert_eq!(buf.push(b"def"), LookaheadState::Overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_size() {
|
||||
let mut buf = LookaheadBuffer::new(0);
|
||||
assert_eq!(buf.push(b"a"), LookaheadState::Overflow);
|
||||
}
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
//! Matroska (MKV) muxer.
|
||||
//!
|
||||
//! Writes EBML header, Segment with tracks, clusters, and cues.
|
||||
//! Designed for streaming writes: clusters are written as data arrives,
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use std::io::{self, Write, Seek, SeekFrom};
|
||||
use super::ebml;
|
||||
use crate::disc::{VideoStream, AudioStream, SubtitleStream, Codec};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
pub struct MkvTrack {
|
||||
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
|
||||
pub codec_id: &'static str,
|
||||
pub language: String,
|
||||
pub codec_private: Option<Vec<u8>>,
|
||||
pub is_default: bool,
|
||||
pub is_forced: bool,
|
||||
// Video-specific
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
pub bit_depth: u8,
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
pub fn video(v: &VideoStream) -> Self {
|
||||
let codec_id = match v.codec {
|
||||
Codec::H264 => "V_MPEG4/ISO/AVC",
|
||||
Codec::Hevc => "V_MPEGH/ISO/HEVC",
|
||||
Codec::Vc1 => "V_MS/VFW/FOURCC",
|
||||
Codec::Mpeg2 => "V_MPEG2",
|
||||
_ => "V_MPEG2",
|
||||
};
|
||||
let (w, h) = parse_resolution(&v.resolution);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_VIDEO,
|
||||
codec_id,
|
||||
language: "und".into(),
|
||||
codec_private: None, // filled later by parser
|
||||
is_default: !v.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: w,
|
||||
pixel_height: h,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio(a: &AudioStream) -> Self {
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
let sr = parse_sample_rate(&a.sample_rate);
|
||||
let ch = parse_channels(&a.channels);
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||||
codec_id,
|
||||
language: a.language.clone(),
|
||||
codec_private: None,
|
||||
is_default: !a.secondary,
|
||||
is_forced: false,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||||
Self {
|
||||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||||
codec_id: "S_HDMV/PGS",
|
||||
language: s.language.clone(),
|
||||
codec_private: None,
|
||||
is_default: false,
|
||||
is_forced: s.forced,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cue point for seeking.
|
||||
struct CuePoint {
|
||||
timestamp_ms: i64,
|
||||
track: usize,
|
||||
cluster_pos: u64, // relative to Segment start
|
||||
}
|
||||
|
||||
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
|
||||
pub struct MkvMuxer<W: Write + Seek> {
|
||||
writer: W,
|
||||
segment_start: u64,
|
||||
cluster_open: bool,
|
||||
cluster_pos: u64,
|
||||
cluster_size_pos: u64,
|
||||
cluster_ts_ms: i64,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks.
|
||||
pub fn new(mut writer: W, tracks: &[MkvTrack], title: Option<&str>, duration_secs: f64) -> io::Result<Self> {
|
||||
// EBML Header
|
||||
let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_READ_VERSION, 1)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_ID_LENGTH, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_MAX_SIZE_LENGTH, 8)?;
|
||||
ebml::write_string(&mut writer, ebml::EBML_DOC_TYPE, "matroska")?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_VERSION, 4)?;
|
||||
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_READ_VERSION, 2)?;
|
||||
ebml::end_master(&mut writer, ebml_pos)?;
|
||||
|
||||
// Segment (unknown size — we'll write cues at the end)
|
||||
ebml::write_id(&mut writer, ebml::SEGMENT)?;
|
||||
ebml::write_unknown_size(&mut writer)?;
|
||||
let segment_start = writer.stream_position()?;
|
||||
|
||||
// Info
|
||||
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
|
||||
ebml::write_uint(&mut writer, ebml::TIMESTAMP_SCALE, 1_000_000)?; // 1ms precision
|
||||
if duration_secs > 0.0 {
|
||||
ebml::write_float(&mut writer, ebml::DURATION, duration_secs * 1000.0)?; // in ms
|
||||
}
|
||||
ebml::write_string(&mut writer, ebml::MUXING_APP, "freemkv")?;
|
||||
ebml::write_string(&mut writer, ebml::WRITING_APP, "freemkv")?;
|
||||
if let Some(t) = title {
|
||||
ebml::write_string(&mut writer, ebml::TITLE, t)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||||
ebml::write_string(&mut writer, ebml::LANGUAGE, &track.language)?;
|
||||
|
||||
if !track.is_default {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
|
||||
}
|
||||
if track.is_forced {
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||||
}
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
codec_private_slots.push(None); // already filled
|
||||
codec_private_filled.push(true);
|
||||
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
|
||||
// Reserve space for codecPrivate — will be filled after first keyframe
|
||||
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
|
||||
let cp_pos = writer.stream_position()?;
|
||||
let placeholder = vec![0u8; 256];
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
|
||||
codec_private_slots.push(Some((cp_pos, 256)));
|
||||
codec_private_filled.push(false);
|
||||
} else {
|
||||
codec_private_slots.push(None);
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
|
||||
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Audio-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
|
||||
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
|
||||
ebml::write_float(&mut writer, ebml::SAMPLING_FREQUENCY, track.sample_rate)?;
|
||||
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
|
||||
if track.bit_depth > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, aud_pos)?;
|
||||
}
|
||||
|
||||
ebml::end_master(&mut writer, entry_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut writer, tracks_pos)?;
|
||||
|
||||
Ok(Self {
|
||||
writer,
|
||||
segment_start,
|
||||
cluster_open: false,
|
||||
cluster_pos: 0,
|
||||
cluster_size_pos: 0,
|
||||
cluster_ts_ms: 0,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
/// Write a single frame.
|
||||
pub fn write_frame(&mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
let pts_ms = pts_ns / 1_000_000;
|
||||
|
||||
// Start new cluster if needed
|
||||
if !self.cluster_open || (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS {
|
||||
if self.cluster_open {
|
||||
// Close current cluster (it's a master with unknown size — we use known size)
|
||||
// Actually, for streaming we keep clusters open-ended. Just start a new one.
|
||||
}
|
||||
self.start_cluster(pts_ms)?;
|
||||
|
||||
// Add cue point at cluster start for keyframes (video track 0)
|
||||
if keyframe && track_idx == 0 {
|
||||
self.cues.push(CuePoint {
|
||||
timestamp_ms: pts_ms,
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write SimpleBlock
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||||
self.frame_count += 1;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish the MKV file: write Cues element.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
// Close final cluster
|
||||
self.end_cluster()?;
|
||||
|
||||
// Write Cues
|
||||
if !self.cues.is_empty() {
|
||||
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
|
||||
for cue in &self.cues {
|
||||
let cp_pos = ebml::start_master(&mut self.writer, ebml::CUE_POINT)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TIME, cue.timestamp_ms as u64)?;
|
||||
let ctp_pos = ebml::start_master(&mut self.writer, ebml::CUE_TRACK_POSITIONS)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_TRACK, cue.track as u64)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CUE_CLUSTER_POSITION, cue.cluster_pos)?;
|
||||
ebml::end_master(&mut self.writer, ctp_pos)?;
|
||||
ebml::end_master(&mut self.writer, cp_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||
}
|
||||
|
||||
self.writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill in a deferred codecPrivate for a track.
|
||||
/// Seeks back to the placeholder, writes the actual data, restores position.
|
||||
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
|
||||
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
|
||||
if data.len() > max_size {
|
||||
// Data too large for reserved space — can't fill in place
|
||||
// This shouldn't happen with 256 bytes reserved
|
||||
return Ok(());
|
||||
}
|
||||
let current = self.writer.stream_position()?;
|
||||
self.writer.seek(SeekFrom::Start(pos))?;
|
||||
// Rewrite: element ID + size + data + zero-pad remainder
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(max_size, 0);
|
||||
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
|
||||
self.writer.seek(SeekFrom::Start(current))?;
|
||||
self.codec_private_filled[track_idx] = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
self.end_cluster()?;
|
||||
}
|
||||
self.cluster_pos = self.writer.stream_position()?;
|
||||
self.cluster_size_pos = ebml::start_master(&mut self.writer, ebml::CLUSTER)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::CLUSTER_TIMESTAMP, ts_ms as u64)?;
|
||||
self.cluster_ts_ms = ts_ms;
|
||||
self.cluster_open = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn end_cluster(&mut self) -> io::Result<()> {
|
||||
if self.cluster_open {
|
||||
ebml::end_master(&mut self.writer, self.cluster_size_pos)?;
|
||||
self.cluster_open = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_simple_block(&mut self, track_num: usize, relative_ts: i16, keyframe: bool, data: &[u8]) -> io::Result<()> {
|
||||
// SimpleBlock: [track_number VINT] [relative_ts i16] [flags u8] [data]
|
||||
// Track number as EBML VINT
|
||||
let track_vint = if track_num < 0x80 {
|
||||
vec![(track_num as u8) | 0x80]
|
||||
} else {
|
||||
vec![0x40 | ((track_num >> 8) as u8), track_num as u8]
|
||||
};
|
||||
|
||||
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
|
||||
|
||||
let block_size = track_vint.len() + 2 + 1 + data.len(); // vint + ts(2) + flags(1) + data
|
||||
ebml::write_id(&mut self.writer, ebml::SIMPLE_BLOCK)?;
|
||||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||||
self.writer.write_all(&track_vint)?;
|
||||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||||
self.writer.write_all(&[flags])?;
|
||||
self.writer.write_all(data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
fn parse_resolution(s: &str) -> (u32, u32) {
|
||||
if s.contains("2160") { (3840, 2160) }
|
||||
else if s.contains("1080") { (1920, 1080) }
|
||||
else if s.contains("720") { (1280, 720) }
|
||||
else if s.contains("576") { (720, 576) }
|
||||
else if s.contains("480") { (720, 480) }
|
||||
else { (1920, 1080) }
|
||||
}
|
||||
|
||||
fn parse_sample_rate(s: &str) -> f64 {
|
||||
if s.contains("96") { 96000.0 }
|
||||
else if s.contains("192") { 192000.0 }
|
||||
else { 48000.0 }
|
||||
}
|
||||
|
||||
fn parse_channels(s: &str) -> u8 {
|
||||
if s.contains("7.1") { 8 }
|
||||
else if s.contains("5.1") { 6 }
|
||||
else if s.contains("stereo") || s.contains("2.0") { 2 }
|
||||
else if s.contains("mono") { 1 }
|
||||
else { 6 }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! MKV muxing pipeline.
|
||||
//!
|
||||
//! Provides BD transport stream → MKV remuxing via composable streams.
|
||||
//!
|
||||
//! The main type is `MkvStream` — wraps any `Write + Seek` output,
|
||||
//! receives raw BD-TS bytes via `write()`, outputs MKV.
|
||||
//!
|
||||
//! ```text
|
||||
//! disc.rip(title, MkvStream::new(file, &title))
|
||||
//! ```
|
||||
//!
|
||||
//! Components (for advanced use):
|
||||
//! - `ts`: BD transport stream demuxer (192-byte packets → PES frames)
|
||||
//! - `ebml`: EBML write primitives for Matroska container
|
||||
//! - `mkv`: MKV muxer (tracks, clusters, blocks, cues)
|
||||
//! - `codec`: Elementary stream parsers (frame boundaries, codec headers)
|
||||
|
||||
pub mod ebml;
|
||||
pub mod ts;
|
||||
pub mod mkv;
|
||||
pub mod codec;
|
||||
pub mod lookahead;
|
||||
pub mod stream;
|
||||
|
||||
pub use stream::MkvStream;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! MkvStream — a Write adapter that demuxes BD-TS and writes MKV.
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! let output = MkvStream::new(file)
|
||||
//! .title(&disc.titles[0])
|
||||
//! .max_buffer(20 * 1024 * 1024);
|
||||
//!
|
||||
//! disc.rip(0, output)?;
|
||||
//! ```
|
||||
|
||||
use std::io::{self, Write, Seek};
|
||||
use super::ts::{TsDemuxer, PesPacket};
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::lookahead::{LookaheadBuffer, LookaheadState, DEFAULT_LOOKAHEAD_SIZE};
|
||||
use crate::disc::{Stream, Title};
|
||||
|
||||
/// Phase of the MkvStream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Phase {
|
||||
/// Collecting data in lookahead buffer, scanning for codec setup.
|
||||
Scanning,
|
||||
/// Header written, streaming directly to muxer.
|
||||
Streaming,
|
||||
}
|
||||
|
||||
/// MKV output stream. Implements `Write`.
|
||||
pub struct MkvStream<W: Write + Seek> {
|
||||
demuxer: TsDemuxer,
|
||||
muxer: Option<MkvMuxer<W>>,
|
||||
writer: Option<W>,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
tracks: Vec<MkvTrack>,
|
||||
title_name: String,
|
||||
duration_secs: f64,
|
||||
lookahead: LookaheadBuffer,
|
||||
phase: Phase,
|
||||
video_tracks_pending: usize,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvStream<W> {
|
||||
/// Create a new MkvStream wrapping an output writer.
|
||||
pub fn new(writer: W) -> Self {
|
||||
Self {
|
||||
demuxer: TsDemuxer::new(&[]),
|
||||
muxer: None,
|
||||
writer: Some(writer),
|
||||
parsers: Vec::new(),
|
||||
pid_to_track: Vec::new(),
|
||||
tracks: Vec::new(),
|
||||
title_name: String::new(),
|
||||
duration_secs: 0.0,
|
||||
lookahead: LookaheadBuffer::new(DEFAULT_LOOKAHEAD_SIZE),
|
||||
phase: Phase::Scanning,
|
||||
video_tracks_pending: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the title metadata (streams, duration, name). Returns self.
|
||||
pub fn title(mut self, title: &Title) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
|
||||
for stream in &title.streams {
|
||||
let (pid, track, parser) = match stream {
|
||||
Stream::Video(v) => {
|
||||
self.video_tracks_pending += 1;
|
||||
(v.pid, MkvTrack::video(v), codec::parser_for_codec(v.codec))
|
||||
}
|
||||
Stream::Audio(a) => (a.pid, MkvTrack::audio(a), codec::parser_for_codec(a.codec)),
|
||||
Stream::Subtitle(s) => (s.pid, MkvTrack::subtitle(s), codec::parser_for_codec(s.codec)),
|
||||
};
|
||||
let track_idx = self.tracks.len();
|
||||
pids.push(pid);
|
||||
self.pid_to_track.push((pid, track_idx));
|
||||
self.parsers.push((pid, parser));
|
||||
self.tracks.push(track);
|
||||
}
|
||||
|
||||
self.demuxer = TsDemuxer::new(&pids);
|
||||
self.title_name = title.playlist.clone();
|
||||
self.duration_secs = title.duration_secs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the lookahead buffer size in bytes. Default 5 MB. Returns self.
|
||||
pub fn max_buffer(mut self, size: usize) -> Self {
|
||||
self.lookahead = LookaheadBuffer::new(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Finalize the MKV file — close cluster, write cues.
|
||||
pub fn finish(mut self) -> io::Result<()> {
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
let remaining = self.demuxer.flush();
|
||||
for pes in &remaining {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
if let Some(muxer) = self.muxer {
|
||||
muxer.finish()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_codec_private(&mut self) -> bool {
|
||||
if self.video_tracks_pending == 0 {
|
||||
return true;
|
||||
}
|
||||
for (pid, parser) in &self.parsers {
|
||||
if let Some(cp) = parser.codec_private() {
|
||||
if let Some((_, track_idx)) = self.pid_to_track.iter().find(|(p, _)| p == pid) {
|
||||
if self.tracks[*track_idx].codec_private.is_none() {
|
||||
self.tracks[*track_idx].codec_private = Some(cp);
|
||||
self.video_tracks_pending -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.video_tracks_pending == 0
|
||||
}
|
||||
|
||||
fn start_streaming(&mut self) -> io::Result<()> {
|
||||
let writer = self.writer.take().ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::Other, "writer already consumed")
|
||||
})?;
|
||||
|
||||
let muxer = MkvMuxer::new(writer, &self.tracks, Some(&self.title_name), self.duration_secs)?;
|
||||
self.muxer = Some(muxer);
|
||||
self.phase = Phase::Streaming;
|
||||
|
||||
// Re-parse and write buffered data
|
||||
let buffered = self.lookahead.drain();
|
||||
if !buffered.is_empty() {
|
||||
let pids: Vec<u16> = self.pid_to_track.iter().map(|(pid, _)| *pid).collect();
|
||||
let mut temp_demuxer = TsDemuxer::new(&pids);
|
||||
let mut packets = temp_demuxer.feed(&buffered);
|
||||
packets.extend(temp_demuxer.flush());
|
||||
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
for pes in &packets {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_one_pes(
|
||||
pid_to_track: &[(u16, usize)],
|
||||
parsers: &mut [(u16, Box<dyn CodecParser>)],
|
||||
muxer: &mut MkvMuxer<W>,
|
||||
pes: &PesPacket,
|
||||
) -> io::Result<()> {
|
||||
let track_idx = match pid_to_track.iter().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, idx)) => *idx,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let parser = match parsers.iter_mut().find(|(pid, _)| *pid == pes.pid) {
|
||||
Some((_, p)) => p,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let frames = parser.parse(pes);
|
||||
for frame in frames {
|
||||
muxer.write_frame(track_idx, frame.pts_ns, frame.keyframe, &frame.data)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Write for MkvStream<W> {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
match self.phase {
|
||||
Phase::Scanning => {
|
||||
// Parse for codec info
|
||||
let packets = self.demuxer.feed(buf);
|
||||
for pes in &packets {
|
||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pes.pid) {
|
||||
let _ = parser.parse(pes);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to buffer
|
||||
let state = self.lookahead.push(buf);
|
||||
|
||||
// Check if we have everything
|
||||
if self.check_codec_private() {
|
||||
self.lookahead.mark_ready();
|
||||
self.start_streaming()?;
|
||||
return Ok(buf.len());
|
||||
}
|
||||
|
||||
match state {
|
||||
LookaheadState::Collecting => Ok(buf.len()),
|
||||
LookaheadState::Overflow => Err(io::Error::new(
|
||||
io::ErrorKind::OutOfMemory,
|
||||
"MKV lookahead buffer overflow — no codec data found within buffer limit",
|
||||
)),
|
||||
LookaheadState::Ready => Ok(buf.len()),
|
||||
}
|
||||
}
|
||||
Phase::Streaming => {
|
||||
let packets = self.demuxer.feed(buf);
|
||||
if let Some(ref mut muxer) = self.muxer {
|
||||
for pes in &packets {
|
||||
Self::process_one_pes(&self.pid_to_track, &mut self.parsers, muxer, pes)?;
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
//! BD Transport Stream demuxer.
|
||||
//!
|
||||
//! Blu-ray uses 192-byte TS packets (not standard 188):
|
||||
//! - 4-byte TP_extra_header (arrival timestamp + copy permission)
|
||||
//! - 188-byte standard MPEG-TS packet
|
||||
//!
|
||||
//! This demuxer extracts PES packets from selected PIDs, with PTS/DTS timestamps.
|
||||
|
||||
/// BD transport stream packet size (4-byte extra header + 188-byte TS).
|
||||
const BD_TS_PACKET_SIZE: usize = 192;
|
||||
|
||||
/// Standard TS packet size.
|
||||
const TS_PACKET_SIZE: usize = 188;
|
||||
|
||||
/// TS sync byte.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
#[derive(Debug)]
|
||||
pub struct PesPacket {
|
||||
/// MPEG-TS PID this packet belongs to.
|
||||
pub pid: u16,
|
||||
/// Presentation timestamp in 90kHz ticks (if present).
|
||||
pub pts: Option<i64>,
|
||||
/// Decode timestamp in 90kHz ticks (if present).
|
||||
pub dts: Option<i64>,
|
||||
/// Elementary stream data (video frame, audio frame, subtitle segment, etc.).
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Per-PID PES reassembly state.
|
||||
struct PesAssembler {
|
||||
pid: u16,
|
||||
buffer: Vec<u8>,
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl PesAssembler {
|
||||
fn new(pid: u16) -> Self {
|
||||
Self {
|
||||
pid,
|
||||
buffer: Vec::with_capacity(256 * 1024),
|
||||
pts: None,
|
||||
dts: None,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new PES packet. Returns the completed previous packet (if any).
|
||||
fn start(&mut self, pts: Option<i64>, dts: Option<i64>) -> Option<PesPacket> {
|
||||
let completed = if self.active && !self.buffer.is_empty() {
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(256 * 1024)),
|
||||
})
|
||||
} else {
|
||||
self.buffer.clear();
|
||||
None
|
||||
};
|
||||
self.pts = pts;
|
||||
self.dts = dts;
|
||||
self.active = true;
|
||||
completed
|
||||
}
|
||||
|
||||
/// Append payload data to the current PES packet.
|
||||
fn push(&mut self, data: &[u8]) {
|
||||
if self.active {
|
||||
self.buffer.extend_from_slice(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush remaining data as a PES packet.
|
||||
fn flush(&mut self) -> Option<PesPacket> {
|
||||
if self.active && !self.buffer.is_empty() {
|
||||
self.active = false;
|
||||
Some(PesPacket {
|
||||
pid: self.pid,
|
||||
pts: self.pts,
|
||||
dts: self.dts,
|
||||
data: std::mem::take(&mut self.buffer),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BD Transport Stream demuxer.
|
||||
pub struct TsDemuxer {
|
||||
assemblers: Vec<PesAssembler>,
|
||||
pid_index: [i16; 8192], // PID → index into assemblers, -1 = not tracked
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let mut pid_index = [-1i16; 8192];
|
||||
let mut assemblers = Vec::with_capacity(pids.len());
|
||||
for (i, &pid) in pids.iter().enumerate() {
|
||||
pid_index[pid as usize] = i as i16;
|
||||
assemblers.push(PesAssembler::new(pid));
|
||||
}
|
||||
Self { assemblers, pid_index }
|
||||
}
|
||||
|
||||
/// Feed a chunk of BD transport stream data (must be aligned to 192-byte packets).
|
||||
/// Returns completed PES packets.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
let mut offset = 0;
|
||||
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
let packet = &data[offset..offset + BD_TS_PACKET_SIZE];
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
|
||||
// Skip 4-byte TP_extra_header, check sync byte
|
||||
if packet[4] != SYNC_BYTE {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = &packet[4..]; // 188-byte standard TS packet
|
||||
|
||||
// Parse TS header
|
||||
let pid = (((ts[1] & 0x1F) as u16) << 8) | ts[2] as u16;
|
||||
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
|
||||
let adaptation = (ts[3] >> 4) & 0x03;
|
||||
|
||||
// Check if we're tracking this PID
|
||||
let idx = self.pid_index[pid as usize];
|
||||
if idx < 0 {
|
||||
continue;
|
||||
}
|
||||
let asm = &mut self.assemblers[idx as usize];
|
||||
|
||||
// Find payload start (skip adaptation field if present)
|
||||
let payload_start = if adaptation == 0x03 || adaptation == 0x02 {
|
||||
// Adaptation field present
|
||||
let af_len = ts[4] as usize;
|
||||
5 + af_len
|
||||
} else {
|
||||
4
|
||||
};
|
||||
|
||||
if payload_start >= TS_PACKET_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
// No payload
|
||||
if adaptation == 0x02 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
// New PES packet starts here — parse PES header
|
||||
let (pts, dts, pes_data_start) = parse_pes_header(payload);
|
||||
if let Some(prev) = asm.start(pts, dts) {
|
||||
completed.push(prev);
|
||||
}
|
||||
if pes_data_start < payload.len() {
|
||||
asm.push(&payload[pes_data_start..]);
|
||||
}
|
||||
} else {
|
||||
// Continuation of current PES packet
|
||||
asm.push(payload);
|
||||
}
|
||||
}
|
||||
|
||||
completed
|
||||
}
|
||||
|
||||
/// Flush all assemblers, returning any remaining PES packets.
|
||||
pub fn flush(&mut self) -> Vec<PesPacket> {
|
||||
let mut completed = Vec::new();
|
||||
for asm in &mut self.assemblers {
|
||||
if let Some(pkt) = asm.flush() {
|
||||
completed.push(pkt);
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a PES packet header, extracting PTS and DTS.
|
||||
/// Returns (pts, dts, offset_to_elementary_stream_data).
|
||||
fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
// PES packet: 00 00 01 [stream_id] [length:2] [flags...]
|
||||
if data.len() < 9 || data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
|
||||
return (None, None, 0);
|
||||
}
|
||||
|
||||
let stream_id = data[3];
|
||||
|
||||
// Some stream IDs don't have the standard PES header extension
|
||||
// (program_stream_map, padding, private_stream_2, ECM, EMM, etc.)
|
||||
if stream_id == 0xBC || stream_id == 0xBE || stream_id == 0xBF
|
||||
|| stream_id == 0xF0 || stream_id == 0xF1 || stream_id == 0xFF
|
||||
{
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
// Standard PES header: [6] = flags1, [7] = flags2, [8] = header_data_length
|
||||
if data.len() < 9 {
|
||||
return (None, None, 6);
|
||||
}
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = 9 + header_data_len;
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
|
||||
if pts_dts_flags >= 2 && data.len() >= 14 {
|
||||
pts = Some(parse_timestamp(&data[9..14]));
|
||||
}
|
||||
if pts_dts_flags == 3 && data.len() >= 19 {
|
||||
dts = Some(parse_timestamp(&data[14..19]));
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
fn parse_timestamp(data: &[u8]) -> i64 {
|
||||
let b0 = data[0] as i64;
|
||||
let b1 = data[1] as i64;
|
||||
let b2 = data[2] as i64;
|
||||
let b3 = data[3] as i64;
|
||||
let b4 = data[4] as i64;
|
||||
|
||||
((b0 >> 1) & 0x07) << 30
|
||||
| b1 << 22
|
||||
| (b2 >> 1) << 15
|
||||
| b3 << 7
|
||||
| b4 >> 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_timestamp() {
|
||||
// Example: PTS = 0 → encoded as 21 00 01 00 01
|
||||
let data = [0x21, 0x00, 0x01, 0x00, 0x01];
|
||||
assert_eq!(parse_timestamp(&data), 0);
|
||||
|
||||
// Example: PTS = 90000 (1 second at 90kHz)
|
||||
// Manual encoding: 33 bits = 0x00015F90
|
||||
// This is just a sanity check that the parser doesn't crash
|
||||
let data2 = [0x21, 0x00, 0x07, 0xE9, 0x01]; // approximate
|
||||
let pts = parse_timestamp(&data2);
|
||||
assert!(pts >= 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_demuxer_empty() {
|
||||
let mut demux = TsDemuxer::new(&[0x1011]);
|
||||
let result = demux.feed(&[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
}
|
||||
+10
-71
@@ -1,81 +1,20 @@
|
||||
//! Platform-specific implementations of raw disc access commands.
|
||||
//!
|
||||
//! Each chipset family (MT1959, Pioneer) implements the Platform trait.
|
||||
//! The trait methods correspond to the 10 command handlers in the per-drive
|
||||
//!
|
||||
//! x86 dispatch order (proven from code + hardware traces):
|
||||
//! 1. unlock() — try activate raw mode
|
||||
//! 2. load_firmware() — if unlock fails, write ld_microcode then retry
|
||||
//! 3. calibrate() — probe disc zones, build speed table, triple SET_CD_SPEED
|
||||
//! 5. read_register() — read hardware registers A and B (mid-rip, retried)
|
||||
//! 6. status() — query feature flags
|
||||
//! 7. probe() — parameterized register read
|
||||
//! 8. set_read_speed()— per-zone SET_CD_SPEED during content reads
|
||||
//!
|
||||
//! The full init sequence is:
|
||||
//! unlock → [load_firmware if fail] × 6 → calibrate × 6 →
|
||||
//! drive_info → registers × 5 → status × 6 → probe
|
||||
//! Platform-specific drive initialization and disc probing.
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::ScsiTransport;
|
||||
|
||||
pub trait Platform {
|
||||
///
|
||||
/// Sends READ_BUFFER with drive-specific mode/buf_id.
|
||||
/// Checks response against drive_signature and mode_active_magic ("MMkv").
|
||||
/// Returns Ok if mode already active (warm), Err if firmware needed (cold).
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Sends WRITE_BUFFER mode=6 with ld_microcode (1888 bytes).
|
||||
/// Verifies with READ_BUFFER buf=0x45 (expects response == 2).
|
||||
/// Then calls unlock() twice to activate mode.
|
||||
/// Only called when unlock() fails (cold boot, firmware not in drive RAM).
|
||||
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Sends pre-built hardware_register_a_cdb. Returns 16 bytes from
|
||||
/// the 36-byte response at offset [4:20].
|
||||
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Sends pre-built hardware_register_b_cdb. Returns 16 bytes from
|
||||
/// the 36-byte response at offset [4:20].
|
||||
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]>;
|
||||
|
||||
///
|
||||
/// Probes disc zones via READ_BUFFER sub_cmd=0x14, builds speed table,
|
||||
/// then commits with triple SET_CD_SPEED (max → nominal → max).
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
fn keepalive(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
///
|
||||
/// Returns 16 bytes of feature data via READ_BUFFER sub_cmd=0x13.
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus>;
|
||||
|
||||
fn probe(&mut self, scsi: &mut dyn ScsiTransport, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>>;
|
||||
|
||||
///
|
||||
/// Looks up LBA in speed_zone_table, sends SET_CD_SPEED.
|
||||
/// Called by x86 before each zone change during content reads.
|
||||
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()>;
|
||||
|
||||
fn timing(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Full init sequence — matches x86 dispatch order.
|
||||
///
|
||||
/// unlock → [load_firmware if fail] × 6 → calibrate × 6
|
||||
pub(crate) trait PlatformDriver {
|
||||
/// Unlock drive + upload firmware if needed.
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
/// Check if raw disc access mode is currently active.
|
||||
fn is_unlocked(&self) -> bool;
|
||||
}
|
||||
/// Calibrate drive for this disc. Probes disc surface so the drive's
|
||||
/// firmware learns the optimal speed for each region. After probing
|
||||
/// the drive manages per-zone speeds internally — the host just reads
|
||||
/// at max speed.
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveStatus {
|
||||
pub unlocked: bool,
|
||||
pub features: [u8; 16],
|
||||
/// True after successful init().
|
||||
fn is_ready(&self) -> bool;
|
||||
}
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
//!
|
||||
//! Two variants share this code:
|
||||
//! MT1959-A: mode=0x01, buffer_id=0x44 (all 10 handlers)
|
||||
//! MT1959-B: mode=0x02, buffer_id=0x77 (handlers 4-9, 0-3 are no-ops)
|
||||
//!
|
||||
//! The handler logic is identical across all 206 drives — only the
|
||||
//! profile data differs (signature, register CDBs, microcode, etc).
|
||||
//!
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::{Platform, DriveStatus};
|
||||
|
||||
/// MT1959 driver state.
|
||||
pub struct Mt1959 {
|
||||
profile: DriveProfile,
|
||||
mode: u8,
|
||||
buffer_id: u8,
|
||||
unlocked: bool,
|
||||
/// Speed table: 64 × u16, built by calibrate().
|
||||
/// Stores zone boundary addresses from disc surface probes.
|
||||
speed_table: [u16; 64],
|
||||
/// Total disc sectors — from READ CAPACITY.
|
||||
disc_sectors: u32,
|
||||
calibrated: bool,
|
||||
/// 4 config bytes stored by calibrate() from initial probe response.
|
||||
/// [0]=speed_mult, [1]=data_byte, [2]=data_byte, [3]=last_speed
|
||||
calibration_config: [u8; 4],
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile) -> Self {
|
||||
let mode = profile.unlock_mode;
|
||||
let buffer_id = profile.unlock_buf_id;
|
||||
Mt1959 {
|
||||
profile,
|
||||
mode,
|
||||
buffer_id,
|
||||
unlocked: false,
|
||||
speed_table: [0u16; 64],
|
||||
disc_sectors: 0,
|
||||
calibrated: false,
|
||||
calibration_config: [0u8; 4],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Build READ_BUFFER with sub_cmd in CDB[3] and address in CDB[4:6].
|
||||
fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
0x3C, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 8) as u8, address as u8,
|
||||
0x00, 0x00, length, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Calls dynamic_read_buffer, validates response size matches expected.
|
||||
/// Returns Ok(bytes_transferred) or Err.
|
||||
fn read_buffer_probe(
|
||||
&self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
|
||||
) -> Result<usize> {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
/// Sends SET_CD_SPEED from CDB template at 0x9A78: BB 00 FF FF FF FF...
|
||||
fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build and send a custom SET_CD_SPEED with a specific speed value.
|
||||
fn set_cd_speed(&self, scsi: &mut dyn ScsiTransport, speed: u16) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(speed);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Core unlock function. Returns the full response on success.
|
||||
///
|
||||
/// r3 = unlock_init_value (1)
|
||||
/// sp[0x1C] = r3
|
||||
/// r3 += unlock_response_size_minus_init (0x3F)
|
||||
/// sp[0] = r3 (= 64 = response size)
|
||||
/// scsi_cmd_wrapper(result, 0x0A, unlock_CDB, response)
|
||||
/// check response[0:4] == drive_signature (LE u32)
|
||||
/// check response[12:16] == "MMkv" (0x766B4D4D LE)
|
||||
/// check response[16:20] version range
|
||||
fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let response_size = self.profile.unlock_init_value as u32
|
||||
+ self.profile.unlock_response_size_minus_init as u32;
|
||||
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
0x00, 0x00, 0x00,
|
||||
0x00, 0x00, response_size as u8, 0x00,
|
||||
];
|
||||
let mut response = vec![0u8; response_size as usize];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
if response.len() >= 4 {
|
||||
let got = &response[0..4];
|
||||
if got != self.profile.drive_signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.drive_signature,
|
||||
got: got.try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if response.len() >= 16 && &response[12..16] != b"MMkv" {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: format!(
|
||||
"mode not active: {:02x}{:02x}{:02x}{:02x}",
|
||||
response[12], response[13], response[14], response[15]
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// If signature + MMkv match, version is almost always OK.
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Sends a short READ_BUFFER probe, retries up to 5 times.
|
||||
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x04, 0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform for Mt1959 {
|
||||
/// Thin wrapper → do_unlock(). Returns Ok if mode active, Err if not.
|
||||
fn unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Two variants with different upload sequences:
|
||||
///
|
||||
/// 1. WRITE_BUFFER mode=6 with ld_microcode
|
||||
/// 2. READ_BUFFER buf=0x45 verify (expect response == 2)
|
||||
/// 3. do_unlock() × 2
|
||||
///
|
||||
/// 1. WRITE_BUFFER with mode=2 buf=0x77 initial handshake (0x9C0 bytes)
|
||||
/// 2. Check response == 2
|
||||
/// 3. READ_BUFFER at offset 0x3000 (16 bytes, firmware metadata check)
|
||||
/// 4. WRITE_BUFFER mode=6 with ld_microcode (16 bytes from payload+16)
|
||||
/// 5. READ verify
|
||||
/// 6. do_unlock() × 5 retries
|
||||
fn load_firmware(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
if microcode.is_empty() {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: "no ld_microcode in profile".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if self.mode == 0x01 {
|
||||
// ── MT1959-A path ──────────────────────────────────────────
|
||||
self.load_firmware_a(scsi)?;
|
||||
} else {
|
||||
// ── MT1959-B path ──────────────────────────────────────────
|
||||
self.load_firmware_b(scsi)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// do_unlock → validate × 5 → send hardware_register_a_cdb (10B) →
|
||||
/// check 36B response → return response[4:20] (16 bytes)
|
||||
fn read_register_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
let cdb = &self.profile.hardware_register_a_cdb;
|
||||
if cdb.len() != 10 {
|
||||
return Err(Error::UnlockFailed { detail: "missing register_a_cdb".into() });
|
||||
}
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_register_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<[u8; 16]> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
let cdb = &self.profile.hardware_register_b_cdb;
|
||||
if cdb.len() != 10 {
|
||||
return Err(Error::UnlockFailed { detail: "missing register_b_cdb".into() });
|
||||
}
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&response[4..20]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
///
|
||||
/// Full calibration sequence:
|
||||
/// 1. do_unlock()
|
||||
/// 2. init_timing()
|
||||
/// 3. read_buffer_probe(0x12, init_addr, 4) — calibration init
|
||||
/// 4. validate_with_retry()
|
||||
/// 5. memset speed_table to 0
|
||||
/// 6. First probe: sub_cmd=0x14, addr=0 → initial speed
|
||||
/// 7. Scan loop: addresses 0x0000-0x5800, step 0x100, detect zone boundaries
|
||||
/// 8. Build loop: scan all zones up to 0x10000, store in speed_table[(speed>>1)-1]
|
||||
/// 9. Triple SET_CD_SPEED: max → drive_nominal_speed → max
|
||||
/// 10. Store 4 calibration config bytes
|
||||
fn calibrate(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// Step 1: ensure unlocked
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
|
||||
// Step 2: read disc capacity
|
||||
let cap_cdb = [0x25u8, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||
let mut cap_buf = [0u8; 8];
|
||||
if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
|
||||
self.disc_sectors = u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1;
|
||||
}
|
||||
|
||||
// Step 3: calibration init — sub_cmd 0x12
|
||||
let init_addr: u16 = 0x0100; // TODO: detect disc type
|
||||
let mut init_resp = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x12, init_addr, &mut init_resp, 4);
|
||||
|
||||
// Step 4: validate
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Step 5: clear speed table
|
||||
self.speed_table = [0u16; 64];
|
||||
|
||||
// Step 6: first probe
|
||||
let mut probe_buf = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x14, 0, &mut probe_buf, 4);
|
||||
let initial_speed = probe_buf[0];
|
||||
self.calibration_config[0] = probe_buf[0];
|
||||
self.calibration_config[1] = probe_buf[1];
|
||||
self.calibration_config[2] = probe_buf[2];
|
||||
|
||||
// Step 7: scan loop — find zone boundaries (0x0000-0x5800, step 0x100)
|
||||
let mut addr: u16 = 0;
|
||||
let mut prev_speed = initial_speed;
|
||||
while addr < 0x5800 {
|
||||
let mut resp = [0u8; 4];
|
||||
if self.read_buffer_probe(scsi, 0x14, addr, &mut resp, 4).is_err() {
|
||||
self.speed_table = [0u16; 64];
|
||||
self.calibration_config = [0u8; 4];
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
if resp[0] != prev_speed {
|
||||
prev_speed = resp[0];
|
||||
}
|
||||
addr = addr.wrapping_add(0x100);
|
||||
}
|
||||
|
||||
// Step 8: build speed table — probe all zones up to 0x10000
|
||||
let mut addr: u32 = 0;
|
||||
let mut prev_speed: u8 = 0;
|
||||
while addr < 0x10000 {
|
||||
let mut resp = [0u8; 4];
|
||||
if self.read_buffer_probe(scsi, 0x14, addr as u16, &mut resp, 4).is_err() {
|
||||
break;
|
||||
}
|
||||
let speed = resp[0];
|
||||
if speed > prev_speed && speed > 0 {
|
||||
let idx = ((speed as usize) >> 1).saturating_sub(1);
|
||||
if idx < 64 && self.speed_table[idx] == 0 {
|
||||
self.speed_table[idx] = addr as u16;
|
||||
}
|
||||
}
|
||||
prev_speed = speed;
|
||||
addr += 0x100;
|
||||
}
|
||||
self.calibration_config[3] = prev_speed;
|
||||
|
||||
// Step 9: triple SET_CD_SPEED — max → nominal → max
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
// Send drive_nominal_speed_cdb (the specific speed from the profile)
|
||||
if self.profile.drive_nominal_speed_cdb.len() >= 6 {
|
||||
let cdb = &self.profile.drive_nominal_speed_cdb;
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
self.calibrated = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// NOT a no-op. Sends 16 bytes from a data buffer to the host via host_write.
|
||||
/// In our context: the x86 reads 16 bytes back. We return the data.
|
||||
/// For now we just acknowledge — the x86 calls this to confirm VM is alive.
|
||||
fn keepalive(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// In driver context: no host_write mechanism. This is a VM-to-host
|
||||
// communication that doesn't translate to a SCSI command.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Full status check:
|
||||
/// 1. Read 4 bytes from host (param input)
|
||||
/// 2. Validate param == 4
|
||||
/// 3. do_unlock()
|
||||
/// 4. validate_with_retry()
|
||||
/// 6. read_buffer_probe(0x13, addr, response, 36)
|
||||
/// 7. validate_with_retry() again
|
||||
/// 8. Check REV32(response[0:4]) == 0x00220054
|
||||
/// 9. If mismatch: call fallback() with speed_zone_table
|
||||
/// 10. If match: host_write(response[4:20]) — 16 bytes features
|
||||
fn status(&mut self, scsi: &mut dyn ScsiTransport) -> Result<DriveStatus> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
self.validate(scsi)?;
|
||||
|
||||
let cdb = self.read_buffer_sub(0x13, 0, 36);
|
||||
let mut response = [0u8; 36];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
let sig = u32::from_be_bytes(response[0..4].try_into().unwrap());
|
||||
let expected = 0x00220054;
|
||||
|
||||
let mut features = [0u8; 16];
|
||||
features.copy_from_slice(&response[4..20]);
|
||||
|
||||
Ok(DriveStatus {
|
||||
unlocked: sig == expected,
|
||||
features,
|
||||
})
|
||||
}
|
||||
|
||||
///
|
||||
/// Three code paths based on param count:
|
||||
/// param=1 (5 bytes in): sub_cmd + nothing else → dynamic_read_buffer
|
||||
/// param=5 (5+4 bytes in): sub_cmd + address → dynamic_read_buffer with addr
|
||||
/// param=9 (9+ bytes in): builds full 12-byte CDB on stack:
|
||||
/// [0x3C, mode, buf_id, sub_cmd, addr[2], addr[1], addr[0], len[2], len[1], len[0]]
|
||||
/// then calls scsi_cmd_wrapper directly
|
||||
fn probe(
|
||||
&mut self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u32, length: u32,
|
||||
) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 16) as u8, (address >> 8) as u8, address as u8,
|
||||
(length >> 16) as u8, (length >> 8) as u8, length as u8,
|
||||
];
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 30_000)?;
|
||||
buf.truncate(result.bytes_transferred);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
///
|
||||
/// Speed management for content reads. Called per zone change.
|
||||
///
|
||||
/// 1. Read 4-byte target LBA from host
|
||||
/// 2. REV(LBA) for big-endian comparison
|
||||
/// 3. Search speed_table[64] for closest entry to LBA
|
||||
/// - Each entry is u16 zone boundary address
|
||||
/// - Find entry with smallest |entry - LBA| distance
|
||||
/// 4. If no match found → call fallback() with speed_calc_table
|
||||
/// 5. If match:
|
||||
/// a. r6 = speed_table[best_idx] (the zone address = speed value)
|
||||
/// b. Position check: read_buffer_probe(0x14, 0x100 | rev16(r6), 4)
|
||||
/// c. SET_CD_SPEED max (BB 00 FF FF FF FF)
|
||||
/// d. Build custom SET_CD_SPEED: BB 00 [r6>>8] [r6&FF] FF FF 00...
|
||||
/// e. Send custom SET_CD_SPEED
|
||||
/// f. Return next speed_table entry to host
|
||||
fn set_read_speed(&mut self, scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
if !self.calibrated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// the byte-swapped LBA against table entries. Since entries are
|
||||
// zone boundary addresses (u16), we compare the low 16 bits.
|
||||
|
||||
// Step 2-3: search speed_table for closest entry
|
||||
let mut best_idx: usize = 0;
|
||||
let mut best_diff: u32 = 0x10000000;
|
||||
let mut found = false;
|
||||
|
||||
for i in 0..64 {
|
||||
let entry = self.speed_table[i] as u32;
|
||||
if entry == 0 { continue; }
|
||||
let diff = if lba > entry { lba - entry } else { entry - lba };
|
||||
if diff < best_diff {
|
||||
best_diff = diff;
|
||||
best_idx = i;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// For now, skip speed adjustment when no table match
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Step 5a: get the matched speed value
|
||||
let speed_val = self.speed_table[best_idx];
|
||||
|
||||
// Step 5b: position check probe
|
||||
let probe_addr = 0x0100 | (speed_val.swap_bytes() as u16);
|
||||
let mut probe_resp = [0u8; 4];
|
||||
let _ = self.read_buffer_probe(scsi, 0x14, probe_addr, &mut probe_resp, 4);
|
||||
|
||||
// Step 5c: SET_CD_SPEED max
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
// Step 5d-e: custom SET_CD_SPEED with the matched speed value
|
||||
let _ = self.set_cd_speed(scsi, speed_val);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// Reads 8 bytes from host, byte-swaps as 32-bit value, returns.
|
||||
/// Has a helper for unaligned 4-byte reads with manual byte copy.
|
||||
/// In driver context: no host communication, so this is a no-op.
|
||||
fn timing(&mut self, _scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// VM-to-host timing measurement, not a SCSI command.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full init sequence — matches x86 dispatch exactly.
|
||||
///
|
||||
/// Phase 1: cmd 0 → [cmd 1 if fail] × 6 retries (unlock + fw upload)
|
||||
/// Phase 2: cmd 4 × 6 retries (calibrate)
|
||||
/// Phase 3: cmd 7 → [cmd 5 fallback] (drive info)
|
||||
/// Phase 4: cmd 2 + cmd 3 × 5 retries (registers)
|
||||
/// Phase 5: cmd 9 × 6 retries (status)
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
// Phase 1: Unlock + firmware upload (6 retries)
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
match self.unlock(scsi) {
|
||||
Ok(_) => { unlocked = true; break; }
|
||||
Err(_) => {
|
||||
if self.load_firmware(scsi).is_ok() {
|
||||
unlocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
return Err(Error::UnlockFailed {
|
||||
detail: "failed after 6 attempts (unlock + load_firmware)".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Calibrate (6 retries)
|
||||
let mut calibrated = false;
|
||||
for _attempt in 0..6 {
|
||||
if self.calibrate(scsi).is_ok() {
|
||||
calibrated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !calibrated {
|
||||
return Err(Error::ScsiError { opcode: 0x3C, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
|
||||
// If fails: cmd 5 fallback (keepalive)
|
||||
// In our context: this fetches a display string, not critical for reads
|
||||
let _ = self.probe(scsi, 0x00, 0, 0x3FF);
|
||||
|
||||
for _attempt in 0..5 {
|
||||
let a_ok = self.read_register_a(scsi).is_ok();
|
||||
let b_ok = self.read_register_b(scsi).is_ok();
|
||||
if a_ok && b_ok { break; }
|
||||
}
|
||||
|
||||
// Phase 5: Status (6 retries)
|
||||
for _attempt in 0..6 {
|
||||
if self.status(scsi).is_ok() { break; }
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_unlocked(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private firmware upload variants ───────────────────────────────────
|
||||
|
||||
impl Mt1959 {
|
||||
///
|
||||
/// Simple: WRITE all microcode → verify buf=0x45 → unlock × 2.
|
||||
fn load_firmware_a(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
let len = microcode.len();
|
||||
|
||||
// WRITE_BUFFER mode=6: send entire microcode payload
|
||||
let cdb = [
|
||||
0x3B, 0x06, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(len >> 16) as u8, (len >> 8) as u8, len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = microcode.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
let verify_cdb = [0x3C, 0x01, 0x45, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00];
|
||||
let mut verify_resp = [0u8; 4];
|
||||
let _ = scsi.execute(
|
||||
&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000,
|
||||
);
|
||||
|
||||
// Unlock × 2
|
||||
self.do_unlock(scsi)?;
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
///
|
||||
/// 1. MODE SELECT (0x55) — send 2496 bytes from ld_microcode
|
||||
/// 2. Check result == 2 (firmware accepted)
|
||||
/// 3. READ_BUFFER mode=6 offset=0x3000 (16 bytes firmware metadata)
|
||||
/// 4. WRITE_BUFFER mode=6 (16 bytes from profile's fw_write_data)
|
||||
/// 5. Vendor verify command (from profile's verify_cdb)
|
||||
/// 6. do_unlock() × 5 retries + 1 final
|
||||
fn load_firmware_b(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let microcode = &self.profile.ld_microcode;
|
||||
|
||||
// Step 1: MODE SELECT (0x55) with vendor mode page data
|
||||
// Transfer: 0x9C0 = 2496 bytes from the firmware payload
|
||||
let write_len = 0x9C0usize.min(microcode.len());
|
||||
let mode_select_cdb = [
|
||||
0x55, 0x10, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = microcode[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// The execute above will Err on SCSI failure. If we get here, command accepted.
|
||||
|
||||
// Step 3: READ_BUFFER mode=6, offset=0x3000, 16 bytes
|
||||
let read_meta_cdb = [0x3C, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
|
||||
|
||||
// Step 4: WRITE_BUFFER mode=6, 16 bytes
|
||||
// This data is stored in profile.fw_write_data (16 bytes)
|
||||
if self.profile.fw_write_data.len() >= 16 {
|
||||
let write2_cdb = [0x3B, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut data2 = self.profile.fw_write_data[..16].to_vec();
|
||||
let _ = scsi.execute(&write2_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
}
|
||||
|
||||
// Step 5: Vendor verify command
|
||||
if self.profile.verify_cdb.len() >= 10 {
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&self.profile.verify_cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
}
|
||||
|
||||
// Step 6: do_unlock() × 5 retries + 1 confirmation
|
||||
for _attempt in 0..5 {
|
||||
if self.do_unlock(scsi).is_ok() {
|
||||
let _ = self.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! MT1959 platform — shared logic for both variants.
|
||||
|
||||
mod variant_a;
|
||||
mod variant_b;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use super::PlatformDriver;
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
|
||||
const MODE_A: u8 = 0x01;
|
||||
const MODE_B: u8 = 0x02;
|
||||
const BUFFER_ID_A: u8 = 0x44;
|
||||
const BUFFER_ID_B: u8 = 0x77;
|
||||
|
||||
// ── SCSI opcodes ──────────────────────────────────────────────────────
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
|
||||
// ── Sub-commands (shared A/B) ─────────────────────────────────────────
|
||||
const SUB_CMD_UNLOCK: u8 = 0x00;
|
||||
const SUB_CMD_INIT: u8 = 0x12;
|
||||
const SUB_CMD_PROBE: u8 = 0x14;
|
||||
const UNLOCK_RESPONSE_SIZE: u8 = 64;
|
||||
const VALIDATE_RESPONSE_SIZE: u8 = 4;
|
||||
const FIRMWARE_ACTIVE_OFFSET: usize = 12;
|
||||
const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
const INIT_ADDR_UHD: u16 = 0x0200;
|
||||
|
||||
// ── Probe scan ranges ─────────────────────────────────────────────────
|
||||
const PROBE_COARSE_END: u16 = 0x5800;
|
||||
const PROBE_FINE_END: u32 = 0x10000;
|
||||
const PROBE_STEP: u16 = 0x0100;
|
||||
const PROBE_RESPONSE_SIZE: u8 = 4;
|
||||
|
||||
// ── Disc type threshold ───────────────────────────────────────────────
|
||||
const UHD_SECTOR_THRESHOLD: u32 = 25_000_000; // ~50 GB
|
||||
const READ_CAPACITY_RESPONSE_SIZE: usize = 8;
|
||||
|
||||
pub struct Mt1959 {
|
||||
pub(crate) profile: DriveProfile,
|
||||
pub(crate) mode: u8,
|
||||
pub(crate) buffer_id: u8,
|
||||
pub(crate) unlocked: bool,
|
||||
probed: bool,
|
||||
}
|
||||
|
||||
impl Mt1959 {
|
||||
pub fn new(profile: DriveProfile, is_variant_b: bool) -> Self {
|
||||
let (mode, buffer_id) = if is_variant_b {
|
||||
(MODE_B, BUFFER_ID_B)
|
||||
} else {
|
||||
(MODE_A, BUFFER_ID_A)
|
||||
};
|
||||
Mt1959 {
|
||||
profile, mode, buffer_id,
|
||||
unlocked: false,
|
||||
probed: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSI helpers (shared by both variants) ─────────────────────────
|
||||
|
||||
pub(crate) fn read_buffer_sub(&self, sub_cmd: u8, address: u16, length: u8) -> [u8; 10] {
|
||||
[
|
||||
SCSI_READ_BUFFER, self.mode, self.buffer_id, sub_cmd,
|
||||
(address >> 8) as u8, address as u8,
|
||||
0x00, 0x00, length, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn read_buffer_probe(
|
||||
&self, scsi: &mut dyn ScsiTransport,
|
||||
sub_cmd: u8, address: u16, buf: &mut [u8], expected: usize,
|
||||
) -> Result<usize> {
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
}
|
||||
|
||||
pub(crate) fn set_cd_speed_max(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let cdb = scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut dummy = [0u8; 0];
|
||||
scsi.execute(&cdb, DataDirection::None, &mut dummy, 5_000)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Unlock (shared) ────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn do_unlock(&mut self, scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, UNLOCK_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
|
||||
scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
if response.len() >= 4 && response[0..4] != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: response[0..4].try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
|
||||
if response.len() >= FIRMWARE_ACTIVE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
self.unlocked = true;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn validate(&self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
for _attempt in 0..5 {
|
||||
let cdb = [
|
||||
0x3C, self.mode, self.buffer_id,
|
||||
SUB_CMD_UNLOCK, 0x00, 0x00,
|
||||
0x00, 0x00, VALIDATE_RESPONSE_SIZE, 0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 })
|
||||
}
|
||||
|
||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut unlocked = false;
|
||||
for _attempt in 0..6 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => { unlocked = true; break; }
|
||||
Err(Error::SignatureMismatch { .. }) => {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
let ok = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
};
|
||||
if ok { unlocked = true; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !unlocked {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Probe disc ─────────────────────────────────────────────────────
|
||||
|
||||
/// Probe the disc surface so the drive firmware learns optimal speeds
|
||||
/// per region. Two passes, then SET_CD_SPEED(max). After this the
|
||||
/// drive manages per-zone speeds internally.
|
||||
fn run_probe(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.do_unlock(scsi)?; }
|
||||
|
||||
// Detect disc type from capacity to select probe mode.
|
||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||
// Verified from MakeMKV strace: BD and UHD use different init addresses.
|
||||
let cap_cdb = [SCSI_READ_CAPACITY, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
|
||||
let disc_sectors = if scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000).is_ok() {
|
||||
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]) + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let init_addr = if disc_sectors > UHD_SECTOR_THRESHOLD { INIT_ADDR_UHD } else { INIT_ADDR_BD };
|
||||
let mut init_resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
let _ = self.read_buffer_probe(scsi, SUB_CMD_INIT, init_addr, &mut init_resp, PROBE_RESPONSE_SIZE as usize);
|
||||
|
||||
self.validate(scsi)?;
|
||||
|
||||
// Pass 1: coarse scan
|
||||
let mut addr: u16 = 0;
|
||||
while addr < PROBE_COARSE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
return Err(Error::ScsiError { opcode: SCSI_READ_BUFFER, status: 0xFF, sense_key: 0 });
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
}
|
||||
|
||||
// Pass 2: fine scan
|
||||
let mut addr: u32 = 0;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self.read_buffer_probe(scsi, SUB_CMD_PROBE, addr as u16, &mut resp, PROBE_RESPONSE_SIZE as usize).is_err() {
|
||||
break;
|
||||
}
|
||||
addr += PROBE_STEP as u32;
|
||||
}
|
||||
|
||||
// Set max speed — drive manages zones from here
|
||||
let _ = self.set_cd_speed_max(scsi);
|
||||
|
||||
self.probed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait ───────────────────────────────────────────────
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if self.unlocked { return Ok(()); }
|
||||
self.run_init(scsi)
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
if !self.unlocked { self.run_init(scsi)?; }
|
||||
if self.probed { return Ok(()); }
|
||||
self.run_probe(scsi)
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.unlocked
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! MT1959 variant A firmware upload.
|
||||
//!
|
||||
//! WRITE_BUFFER (0x3B) → verify READ_BUFFER (0x45) → unlock × 2
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const VERIFY_BUFFER_ID: u8 = 0x45;
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER
|
||||
let len = firmware.len();
|
||||
let cdb = [
|
||||
SCSI_WRITE_BUFFER, 0x06, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(len >> 16) as u8, (len >> 8) as u8, len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
|
||||
let verify_cdb = [super::SCSI_READ_BUFFER, super::MODE_A, VERIFY_BUFFER_ID, 0x00, 0x00, 0x00, 0x00, 0x00, super::VALIDATE_RESPONSE_SIZE, 0x00];
|
||||
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::FromDevice, &mut verify_resp, 5_000);
|
||||
|
||||
// Double unlock after firmware upload
|
||||
mt.do_unlock(scsi)?;
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! MT1959 variant B firmware upload.
|
||||
//!
|
||||
//! MODE SELECT (0x55) → read metadata → WRITE_BUFFER → vendor verify (0xF1) → unlock × 5+1
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use super::Mt1959;
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const FIRMWARE_MAX_SIZE: usize = 0x9C0;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload firmware via MODE SELECT
|
||||
let write_len = FIRMWARE_MAX_SIZE.min(firmware.len());
|
||||
let mode_select_cdb = [
|
||||
SCSI_MODE_SELECT, 0x10, 0x00,
|
||||
0x00, 0x00, 0x00,
|
||||
(write_len >> 16) as u8, (write_len >> 8) as u8, write_len as u8,
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
|
||||
let read_meta_cdb = [SCSI_READ_BUFFER, 0x06, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(&read_meta_cdb, DataDirection::FromDevice, &mut meta_resp, 5_000);
|
||||
|
||||
// Step 3: Write extra firmware data (all zeros)
|
||||
let write_extra_cdb = [SCSI_WRITE_BUFFER, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI)
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&VENDOR_VERIFY, DataDirection::None, &mut dummy, 5_000);
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then final attempt)
|
||||
for _attempt in 0..5 {
|
||||
if mt.do_unlock(scsi).is_ok() {
|
||||
let _ = mt.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
mt.do_unlock(scsi)?;
|
||||
Ok(())
|
||||
}
|
||||
+81
-191
@@ -1,182 +1,76 @@
|
||||
//! Drive profile loading and matching.
|
||||
//!
|
||||
//! The profile contains all per-drive data needed by the MT1959 platform handlers.
|
||||
//! Profiles are loaded from JSON so new drives can be added without rebuilding.
|
||||
|
||||
use serde::Deserialize;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
///
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
// ── Drive identity (from INQUIRY + GET_CONFIG) ─────────────────────
|
||||
|
||||
/// Drive vendor from INQUIRY[8:16] (e.g. "HL-DT-ST")
|
||||
/// Top-level profiles file — keyed by chipset + variant.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ProfilesFile {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
|
||||
/// Drive product from INQUIRY[16:32] (e.g. "BD-RE BU40N")
|
||||
pub mt1959_a: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
|
||||
/// Firmware revision from INQUIRY[32:36] (e.g. "1.03")
|
||||
pub mt1959_b: Vec<DriveProfile>,
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
|
||||
/// Firmware type from INQUIRY[36:43] (e.g. "NM00000")
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
|
||||
/// Firmware build date from GET_CONFIG 010C (e.g. "211810241934")
|
||||
#[serde(default)]
|
||||
pub firmware_date: String,
|
||||
|
||||
// ── Platform variant ───────────────────────────────────────────────
|
||||
|
||||
/// Chipset family: "mediatek" or "renesas".
|
||||
#[serde(default)]
|
||||
pub chipset: Chipset,
|
||||
|
||||
/// Program variant: "mt1959_a" or "mt1959_b".
|
||||
/// Determines unlock mode/buf_id and handler layout.
|
||||
#[serde(default)]
|
||||
pub program: String,
|
||||
|
||||
/// READ_BUFFER mode byte (0x01 for mt1959_a, 0x02 for mt1959_b).
|
||||
#[serde(default = "default_unlock_mode")]
|
||||
pub unlock_mode: u8,
|
||||
|
||||
/// READ_BUFFER buffer ID (0x44 for mt1959_a, 0x77 for mt1959_b).
|
||||
#[serde(default = "default_unlock_buf_id")]
|
||||
pub unlock_buf_id: u8,
|
||||
|
||||
|
||||
/// Used with unlock_response_size_minus_init to compute response size.
|
||||
#[serde(default = "default_init_value")]
|
||||
pub unlock_init_value: u8,
|
||||
|
||||
/// Response size = unlock_init_value + this (e.g. 1 + 63 = 64).
|
||||
#[serde(default = "default_response_size_minus_init")]
|
||||
pub unlock_response_size_minus_init: u8,
|
||||
|
||||
/// Per-drive signature checked against unlock response[0:4].
|
||||
#[serde(default, deserialize_with = "deserialize_hex4")]
|
||||
pub drive_signature: [u8; 4],
|
||||
|
||||
|
||||
/// Uploaded on cold boot when unlock fails. ~1888 bytes typically.
|
||||
/// Contains volatile RAM-only runtime code for the drive's MediaTek SOC.
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub ld_microcode: Vec<u8>,
|
||||
|
||||
// ── Handlers 2/3: register reads ──────────────────────────────────
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub hardware_register_a_cdb: Vec<u8>,
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub hardware_register_b_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// 16 bytes written via WRITE_BUFFER mode=6 during B firmware upload.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub fw_write_data: Vec<u8>,
|
||||
|
||||
/// Vendor-specific verify CDB after B firmware upload (10 bytes).
|
||||
/// B-only. Empty for A-variant drives.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub verify_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// Pre-built SET_CD_SPEED CDB with drive's nominal speed (12 bytes).
|
||||
/// Used in calibration "triple play": max → this → max.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub drive_nominal_speed_cdb: Vec<u8>,
|
||||
|
||||
|
||||
/// for per-zone speed decisions. Per-drive calibration constants.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub speed_zone_table: Vec<u8>,
|
||||
|
||||
/// raw sector reads for speed math.
|
||||
#[serde(default, deserialize_with = "deserialize_hex_vec")]
|
||||
pub speed_calc_table: Vec<u8>,
|
||||
|
||||
|
||||
/// Drive supports reading DVDs regardless of region code.
|
||||
#[serde(default)]
|
||||
pub dvd_all_regions: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray sector reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_read: bool,
|
||||
|
||||
/// Drive supports raw Blu-ray metadata reads.
|
||||
#[serde(default)]
|
||||
pub bd_raw_metadata: bool,
|
||||
|
||||
/// Drive supports unrestricted read speed.
|
||||
#[serde(default)]
|
||||
pub unrestricted_speed: bool,
|
||||
|
||||
// ── Metadata ──────────────────────────────────────────────────────
|
||||
|
||||
#[serde(default)]
|
||||
pub drive_id: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub drive_version: String,
|
||||
pub renesas: Vec<DriveProfile>,
|
||||
}
|
||||
|
||||
fn default_unlock_mode() -> u8 { 0x01 }
|
||||
fn default_unlock_buf_id() -> u8 { 0x44 }
|
||||
fn default_init_value() -> u8 { 1 }
|
||||
fn default_response_size_minus_init() -> u8 { 0x3F }
|
||||
/// Drive identity — matched against INQUIRY data.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
#[serde(default)]
|
||||
pub firmware_date: String,
|
||||
}
|
||||
|
||||
/// Drive chipset family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
|
||||
pub enum Chipset {
|
||||
#[serde(rename = "mediatek")]
|
||||
MediaTek,
|
||||
#[serde(rename = "renesas")]
|
||||
/// Per-drive profile.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
pub identity: Identity,
|
||||
#[serde(default, deserialize_with = "deserialize_hex4")]
|
||||
pub signature: [u8; 4],
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub firmware: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mt1959A,
|
||||
Mt1959B,
|
||||
Renesas,
|
||||
}
|
||||
|
||||
impl Default for Chipset {
|
||||
fn default() -> Self { Chipset::MediaTek }
|
||||
}
|
||||
|
||||
impl Chipset {
|
||||
impl Platform {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Chipset::MediaTek => "MediaTek MT1959",
|
||||
Chipset::Renesas => "Renesas",
|
||||
Platform::Mt1959A => "MediaTek MT1959",
|
||||
Platform::Mt1959B => "MediaTek MT1959",
|
||||
Platform::Renesas => "Renesas",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hex/base64 parsing ─────────────────────────────────────────────────
|
||||
/// Result of profile lookup.
|
||||
pub struct ProfileMatch {
|
||||
pub profile: DriveProfile,
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_hex4(s: &str) -> Result<[u8; 4]> {
|
||||
if s.len() != 8 {
|
||||
return Err(Error::ProfileParse { detail: format!("expected 8 hex chars, got {}", s.len()) });
|
||||
return Err(Error::ProfileParse);
|
||||
}
|
||||
let mut out = [0u8; 4];
|
||||
for i in 0..4 {
|
||||
out[i] = u8::from_str_radix(&s[i*2..i*2+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> Result<Vec<u8>> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err(Error::ProfileParse { detail: "odd hex length".into() });
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
for i in (0..s.len()).step_by(2) {
|
||||
out.push(u8::from_str_radix(&s[i..i+2], 16)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("bad hex: {e}") })?);
|
||||
.map_err(|_| Error::ProfileParse)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -188,13 +82,6 @@ where D: serde::Deserializer<'de> {
|
||||
parse_hex4(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn deserialize_hex_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
if s.is_empty() { return Ok(Vec::new()); }
|
||||
parse_hex(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
fn deserialize_base64<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
use base64::Engine;
|
||||
@@ -207,48 +94,51 @@ where D: serde::Deserializer<'de> {
|
||||
|
||||
// ── Loading ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Bundled profiles — compiled into the binary.
|
||||
const BUNDLED_PROFILES: &str = include_str!("../profiles.json");
|
||||
|
||||
/// Load profiles from the bundled database.
|
||||
pub fn load_bundled() -> Result<Vec<DriveProfile>> {
|
||||
pub fn load_bundled() -> Result<ProfilesFile> {
|
||||
load_from_str(BUNDLED_PROFILES)
|
||||
}
|
||||
|
||||
/// Load all profiles from a JSON array file.
|
||||
pub fn load_all(path: &std::path::Path) -> Result<Vec<DriveProfile>> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
load_from_str(&data)
|
||||
}
|
||||
|
||||
fn load_from_str(data: &str) -> Result<Vec<DriveProfile>> {
|
||||
let arr: Vec<DriveProfile> = serde_json::from_str(data)
|
||||
.map_err(|e| Error::ProfileParse { detail: format!("JSON: {e}") })?;
|
||||
Ok(arr)
|
||||
fn load_from_str(data: &str) -> Result<ProfilesFile> {
|
||||
serde_json::from_str(data)
|
||||
.map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
pub fn find_by_drive_id<'a>(
|
||||
profiles: &'a [DriveProfile],
|
||||
pub fn find_by_drive_id(
|
||||
profiles: &ProfilesFile,
|
||||
drive_id: &crate::identity::DriveId,
|
||||
) -> Option<&'a DriveProfile> {
|
||||
) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
|
||||
// Match all four INQUIRY fields
|
||||
profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
&& p.firmware_date.trim() == drive_id.firmware_date.trim()
|
||||
})
|
||||
// Fallback: match without date
|
||||
.or_else(|| profiles.iter().find(|p| {
|
||||
p.vendor_id.trim() == v
|
||||
&& p.product_revision.trim() == r
|
||||
&& p.vendor_specific.trim() == vs
|
||||
}))
|
||||
for (platform, list) in [
|
||||
(Platform::Mt1959A, &profiles.mt1959_a),
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
}) {
|
||||
return Some(ProfileMatch { profile: p.clone(), platform });
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -268,9 +158,9 @@ mod tests {
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let p = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(p.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(p.vendor_specific.trim(), "NM00000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-93
@@ -1,41 +1,14 @@
|
||||
//! Drive speed control — query and set read speeds.
|
||||
//!
|
||||
//! Uses MMC-6 SET CD SPEED (0xBB) command.
|
||||
//! Reference: MMC-6 §6.30
|
||||
//! Drive speed constants.
|
||||
|
||||
/// Disc read speed.
|
||||
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum DriveSpeed {
|
||||
/// Blu-ray 1x = 4,500 KB/s
|
||||
BD1x,
|
||||
/// Blu-ray 2x = 9,000 KB/s
|
||||
BD2x,
|
||||
/// Blu-ray 4x = 18,000 KB/s
|
||||
BD4x,
|
||||
/// Blu-ray 6x = 27,000 KB/s
|
||||
BD6x,
|
||||
/// Blu-ray 8x = 36,000 KB/s
|
||||
BD8x,
|
||||
/// Blu-ray 10x = 45,000 KB/s
|
||||
BD10x,
|
||||
/// Blu-ray 12x = 54,000 KB/s
|
||||
BD12x,
|
||||
/// DVD 1x = 1,385 KB/s
|
||||
DVD1x,
|
||||
/// DVD 2x = 2,770 KB/s
|
||||
DVD2x,
|
||||
/// DVD 4x = 5,540 KB/s
|
||||
DVD4x,
|
||||
/// DVD 8x = 11,080 KB/s
|
||||
DVD8x,
|
||||
/// DVD 16x = 22,160 KB/s
|
||||
DVD16x,
|
||||
/// Maximum speed — drive decides
|
||||
BD1x, BD2x, BD4x, BD6x, BD8x, BD10x, BD12x,
|
||||
DVD1x, DVD2x, DVD4x, DVD8x, DVD16x,
|
||||
Max,
|
||||
}
|
||||
|
||||
impl DriveSpeed {
|
||||
/// Convert to KB/s for MMC-6 SET CD SPEED command.
|
||||
pub fn to_kbps(self) -> u16 {
|
||||
match self {
|
||||
DriveSpeed::BD1x => 4_500,
|
||||
@@ -53,71 +26,10 @@ impl DriveSpeed {
|
||||
DriveSpeed::Max => 0xFFFF,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from KB/s value, rounding to nearest standard speed.
|
||||
pub fn from_kbps(kbps: u16) -> Self {
|
||||
match kbps {
|
||||
0..=2_000 => DriveSpeed::DVD1x,
|
||||
2_001..=4_000 => DriveSpeed::DVD2x,
|
||||
4_001..=6_000 => DriveSpeed::BD1x,
|
||||
6_001..=13_000 => DriveSpeed::BD2x,
|
||||
13_001..=22_000 => DriveSpeed::BD4x,
|
||||
22_001..=31_000 => DriveSpeed::BD6x,
|
||||
31_001..=40_000 => DriveSpeed::BD8x,
|
||||
40_001..=49_000 => DriveSpeed::BD10x,
|
||||
49_001..=u16::MAX => DriveSpeed::BD12x,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
DriveSpeed::BD1x => "BD 1x",
|
||||
DriveSpeed::BD2x => "BD 2x",
|
||||
DriveSpeed::BD4x => "BD 4x",
|
||||
DriveSpeed::BD6x => "BD 6x",
|
||||
DriveSpeed::BD8x => "BD 8x",
|
||||
DriveSpeed::BD10x => "BD 10x",
|
||||
DriveSpeed::BD12x => "BD 12x",
|
||||
DriveSpeed::DVD1x => "DVD 1x",
|
||||
DriveSpeed::DVD2x => "DVD 2x",
|
||||
DriveSpeed::DVD4x => "DVD 4x",
|
||||
DriveSpeed::DVD8x => "DVD 8x",
|
||||
DriveSpeed::DVD16x => "DVD 16x",
|
||||
DriveSpeed::Max => "Max",
|
||||
}
|
||||
}
|
||||
|
||||
/// All standard Blu-ray speeds.
|
||||
pub fn all_bd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::BD1x, DriveSpeed::BD2x, DriveSpeed::BD4x,
|
||||
DriveSpeed::BD6x, DriveSpeed::BD8x, DriveSpeed::BD10x, DriveSpeed::BD12x]
|
||||
}
|
||||
|
||||
/// All standard DVD speeds.
|
||||
pub fn all_dvd() -> &'static [DriveSpeed] {
|
||||
&[DriveSpeed::DVD1x, DriveSpeed::DVD2x, DriveSpeed::DVD4x,
|
||||
DriveSpeed::DVD8x, DriveSpeed::DVD16x]
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DriveSpeed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{} ({} KB/s)", self.label(), self.to_kbps())
|
||||
write!(f, "{:?} ({} KB/s)", self, self.to_kbps())
|
||||
}
|
||||
}
|
||||
|
||||
/// Build SET CD SPEED CDB — MMC-6 §6.30
|
||||
pub fn set_cd_speed_cdb(read_speed: DriveSpeed) -> [u8; 12] {
|
||||
let kbps = read_speed.to_kbps();
|
||||
[
|
||||
0xBB, // SET CD SPEED opcode
|
||||
0x00, // reserved
|
||||
(kbps >> 8) as u8, // read speed MSB
|
||||
kbps as u8, // read speed LSB
|
||||
0xFF, // write speed MSB (0xFFFF = don't change)
|
||||
0xFF, // write speed LSB
|
||||
0x00, 0x00, 0x00, 0x00, // reserved
|
||||
0x00, 0x00, // reserved
|
||||
]
|
||||
}
|
||||
|
||||
+32
-39
@@ -80,16 +80,17 @@ impl UdfFs {
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = parts.last().unwrap();
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
let (data_lba, _) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
Ok(self.partition_start + data_lba)
|
||||
}
|
||||
@@ -102,18 +103,19 @@ impl UdfFs {
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
|
||||
// Find the file
|
||||
let filename = parts.last().unwrap();
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
// Read the file's ICB to get its data extent
|
||||
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
|
||||
@@ -197,9 +199,7 @@ impl UdfFs {
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, session: &mut DriveSession, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(session, meta_lba)?;
|
||||
extents.first().copied().ok_or_else(|| Error::DiscError {
|
||||
detail: "no allocation descriptors in ICB".into(),
|
||||
})
|
||||
extents.first().copied().ok_or_else(|| Error::DiscRead { sector: 0 })
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
@@ -225,9 +225,7 @@ impl UdfFs {
|
||||
let l_ad = u32::from_le_bytes([icb[172], icb[173], icb[174], icb[175]]) as usize;
|
||||
(176 + l_ea, l_ad)
|
||||
}
|
||||
_ => return Err(Error::DiscError {
|
||||
detail: format!("unexpected ICB tag {} at meta_lba {}", tag, meta_lba),
|
||||
}),
|
||||
_ => return Err(Error::DiscRead { sector: 0 }),
|
||||
};
|
||||
|
||||
let mut extents = Vec::new();
|
||||
@@ -235,8 +233,8 @@ impl UdfFs {
|
||||
|
||||
for i in 0..num_descriptors {
|
||||
let off = ad_offset + i * 8;
|
||||
if off + 8 > 2048 {
|
||||
break; // TODO: follow Allocation Extent Descriptors (tag 258) for overflow
|
||||
if off + 8 > icb.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let raw_len = u32::from_le_bytes([icb[off], icb[off + 1], icb[off + 2], icb[off + 3]]);
|
||||
@@ -263,16 +261,17 @@ impl UdfFs {
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: part.to_string() }
|
||||
)?;
|
||||
}
|
||||
let filename = parts.last().unwrap();
|
||||
let filename = match parts.last() {
|
||||
Some(f) => f,
|
||||
None => return Err(Error::UdfNotFound { path: path.to_string() }),
|
||||
};
|
||||
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),
|
||||
})?;
|
||||
}).ok_or_else(|| Error::UdfNotFound { path: path.to_string() }
|
||||
)?;
|
||||
|
||||
let alloc_extents = self.read_icb_extents(session, entry.meta_lba)?;
|
||||
let mut disc_extents = Vec::new();
|
||||
@@ -302,9 +301,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
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),
|
||||
});
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||
@@ -344,16 +341,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
}
|
||||
|
||||
if partition_start == 0 {
|
||||
return Err(Error::DiscError { detail: "UDF: no Partition Descriptor found".into() });
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// 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(),
|
||||
})?;
|
||||
let lvd_sec = lvd_sector.ok_or_else(|| Error::DiscRead { sector: 0 })?;
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
@@ -410,9 +405,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
|
||||
|
||||
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),
|
||||
});
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
}
|
||||
|
||||
// Root Directory ICB: long_ad at FSD offset 400
|
||||
|
||||
Reference in New Issue
Block a user