Compare commits

...
35 Commits
Author SHA1 Message Date
MattJackson ca732d57e7 Bump to v0.4.1 2026-04-07 21:01:31 -07:00
MattJackson f357e0fe26 README: update dep version to 0.4, update architecture 2026-04-07 20:57:32 -07:00
MattJackson 976aeb4848 Labels: catch panics — never break disc scan 2026-04-07 20:36:26 -07:00
MattJackson 2bea69c7d9 Release v0.4.0: labels rewrite, eject, capture fix 2026-04-07 20:35:11 -07:00
MattJackson 0bb815ca22 Restructure labels: detect-then-parse, named parsers, raw disc data
Architecture:
- Each BD-J format in own file: paramount.rs, criterion.rs, pixelogic.rs, ctrm.rs
- Standard interface: detect() → bool, parse() → Option<Vec<StreamLabel>>
- PARSERS array in mod.rs — drop in a new parser with one line
- Shared vocab.rs for BD spec codec names only (MLP→TrueHD, AC3→Dolby Digital)
- All other label data passes through raw from disc — no guessing

Changes:
- New: paramount.rs (playlists.xml — Paramount/onQ format)
- Renamed: bluray_project.rs → pixelogic.rs
- Renamed: stream_properties.rs → criterion.rs
- Merged: language_streams.rs + menu_base.rs → ctrm.rs
- Removed: jar module (superseded by labels), dead apply functions
- Added: DriveSession::eject() with PREVENT ALLOW MEDIUM REMOVAL
- Added: DiscRegion enum (Free/BluRay/Dvd)
- Fixed: capture sector ranges now include all files (only skip STREAM/)
- Renamed: StreamLabel.region → variant (not a BD spec field)
2026-04-07 20:29:44 -07:00
MattJackson aa188751e4 Simplify labels API: one call, labels on streams or nothing
labels::apply(session, udf, titles) does everything internally.
Disc::scan() is one line: crate::labels::apply().
No intermediate variables, no fallback logic in disc.rs.

App reads disc.titles[].streams — labels already applied if
disc had config files, empty otherwise. MPLS data always there.
2026-04-07 18:46:29 -07:00
MattJackson ba44f7d928 Add labels module: 4 disc file parsers for stream labels
src/labels/ with 4 parsers tried in order:
1. language_streams.txt (Warner CTRM CSV)
2. menu_base.prop (Warner CTRM properties)
3. streamproperties.xml + playbackconfig.xml (Criterion XML)
4. bluray_project.bin (Pixelogic binary tokens)

Disc::scan() calls labels::extract() → apply_disc_labels().
If no disc files found, streams keep MPLS data as-is.
No JAR fallback — disc files or nothing.

Covers 4/8 discs with JARs (Dunkirk UHD, V for Vendetta BD,
Being There, Barbie). Remaining 3 (Civil War, Dune, V for
Vendetta UHD) have no disc config files.
2026-04-07 18:42:33 -07:00
MattJackson 8a95787426 JAR bytecode tracer POC — extract display names from BD-J enum classes
Traces <clinit> bytecode to find ldc/putstatic pairs that map
enum field names to display name strings. Pattern: new X, dup,
ldc "English", invokespecial X.<init>, putstatic X.a.

Proven on Dune UHD JAR: aw.a="English", aw.b="French", etc.
Next: trace mapping class (ISO code → enum field) to complete the chain.
2026-04-07 16:54:30 -07:00
MattJackson aebe6a256b Fix JAR label matching: by language+codec for label format, by index for TextField
Labels like eng_MLP_ now match to the correct stream by checking language
and codec hint against the stream's properties. Fixes Barbie where labels
were swapped (TrueHD label was on DD stream). TextField format (Civil War)
still uses index matching.

TrackLabel now carries language + codec_hint for structured matching.
2026-04-07 16:27:47 -07:00
MattJackson 791b832f25 Rewrite JAR parser: multi-format with TextField support
- Extract all class strings once, try format parsers in chain
- Format 1: TextField,Audio{N} (A24/Lionsgate) — fixes Civil War
- Format 2: eng_MLP_ label strings (Warner UHD) — Barbie, Dune
- Format 3: playlist-only (MAIN_FEATURE etc.)
- Simplified TrackLabel to just description + raw
- 5 unit tests for both formats
- Tested on 12 disc captures: Civil War now gets 3 audio + 2 sub labels

Known issues: Barbie labels swapped, Dune returns 0 labels (different format?)
2026-04-07 16:23:14 -07:00
MattJackson 580ce2277d Add serial_number to DriveId from GET_CONFIG 0x0108 2026-04-07 16:14:55 -07:00
MattJackson 8a556c5c63 Merge JAR labels into streams during Disc::scan()
apply_jar_labels() matches JAR audio/subtitle labels to streams by
position and sets AudioStream.label. Apps read labels directly from
streams instead of doing their own JAR matching.
2026-04-07 16:10:43 -07:00
MattJackson 520a3f912c Refactor Stream to enum with typed variants (Video, Audio, Subtitle)
Each stream type has only its relevant fields. No more HDR on audio
or channels on video. Added SubtitleStream.forced field (TODO: parse).
Removed display helpers from lib (belongs in CLI).
2026-04-07 16:08:45 -07:00
MattJackson 6f0eea48fa Add Clip struct to Title — expose clip references for playlist analysis
Title.clips[] contains clip_id, in/out times, duration, source_packets.
Apps can detect fake/scrambled playlists by checking unique clip count
vs total (253 clips referencing 2 unique = fake). Removed clip_count
field (use clips.len()).
2026-04-07 16:03:56 -07:00
MattJackson b01010f2d1 Fix title sizes: use pkt_count*192, fix metadata partition range
- Size from source_packet_count * 192 (matches libbluray approach)
- metadata_sector_ranges() uses actual metadata partition size from ICB
 instead of arbitrary +256 margin (fixes Top Gun missing CLIPINF)
- Store metadata_sectors in UdfFs
- Fix CLPI CPI bit-packed field parsing (partial, EP map still needs work)
2026-04-07 15:49:53 -07:00
MattJackson fd443e3a2f Add disc title, format, streams to Disc::scan() — move logic from CLI to lib
- Disc.volume_id: UDF Volume ID from PVD (always present)
- Disc.meta_title: from META/DL/bdmt_eng.xml (falls back to other languages)
- Disc.format: UHD/BluRay/DVD detected from video codec
- Disc.capacity_bytes, Disc.layers
- Disc.jar_labels: extracted from BDMV/JAR
- Fixed MPLS STN parsing: 16-byte header (was 8), proper stream entry offsets
- Streams now include: HDR, color space, Dolby Vision EL, secondary audio/video
- parse_dstring() for UDF d-string fields
- wait_ready() polls TEST UNIT READY before unlock

Tested on 12 disc captures — all return correct titles, streams, format.
2026-04-07 15:36:05 -07:00
MattJackson 8d85a9d778 Add metadata_sector_ranges() and max_metadata_sector() to UdfFs
Walk UDF tree to discover sector ranges for all metadata files
(MPLS, CLPI, JAR, AACS certs, etc). Skips STREAM/, BACKUP/,
DUPLICATE/, MKB_RO.inf, ContentHash. Exposes partition_start()
and metadata_start() getters. Used by bdemu smart capture.
2026-04-07 14:43:42 -07:00
MattJackson 237528b385 Add macOS SCSI support via IOKit SCSITaskDeviceInterface
IOKit backend for macOS optical drives. Accepts BSD device paths
(/dev/disk2), walks IORegistry to find authoring device, sends
SCSI commands through SCSITaskDeviceInterface COM vtable.
2026-04-07 13:15:06 -07:00
MattJackson 492a2966c0 Add docs/README.md — reading order and index for all documentation
Start with disc-to-rip.md (the big picture), then architecture,
drive access, disc formats (UDF/MPLS/CLPI), and AACS encryption.
2026-04-07 12:36:29 -07:00
MattJackson 5fa6b064da Remove standalone binaries — functionality lives in freemkv CLI and tests
Deleted:
- freemkv_info.rs — duplicate of freemkv drive-info CLI command
- freemkv_test.rs — duplicate of bdemu capture-disc
- aacs_test.rs — covered by inline #[test] functions (31 tests)

libfreemkv is a library. CLI tools belong in the freemkv repo.
Dev/debug tools belong in (internal).
2026-04-07 12:34:10 -07:00
MattJackson df652c5735 Restructure: scsi/ and aacs/ as module directories
- scsi.rs → scsi/mod.rs (trait, constants, CDB builders) + scsi/linux.rs (SG_IO)
 Ready for scsi/macos.rs and scsi/windows.rs when needed.
- aacs.rs + aacs_handshake.rs → aacs/mod.rs (keys, decrypt) + aacs/handshake.rs (SCSI auth)
- All internal refs use super:: within module, crate:: across modules
- Zero warnings, 31 tests passing
2026-04-07 12:31:05 -07:00
MattJackson d0c5c7fb97 Named constants, error codes, SCSI opcodes, flow documentation
- Error codes as public constants (E_DEVICE_NOT_FOUND etc.) — single source of truth
- SCSI opcodes as named constants (SCSI_READ_10, SCSI_REPORT_KEY, etc.)
- AACS key class constant, MKB format constant
- No magic numbers in CDB construction
- docs/disc-to-rip.md — end-to-end flow from disc insert to decrypted content
- Links all module docs together as a starting point
2026-04-07 12:26:30 -07:00
MattJackson 6ab355245f Update README: remove hard numbers, add AACS and disc scanning
- Removed specific drive counts (was "206 bundled")
- Removed version-specific install instructions
- Added Disc::scan() + ContentReader as primary API example
- Added AACS section explaining transparent decryption
- Added docs/ link for technical documentation
- Added E7xxx AACS error range
- Evergreen — no dates or counts that need manual maintenance
2026-04-07 12:21:08 -07:00
MattJackson 272215c551 Clean up for public release: docs, zero warnings, no hardcoded paths
Documentation:
- docs/aacs.md — AACS encryption (1.0 + 2.0), key resolution, decrypt
- docs/udf.md — UDF 2.50 filesystem with metadata partitions
- docs/mpls.md — MPLS playlist format, STN stream table
- docs/clpi.md — CLPI clip info, EP map, sector extents
- docs/architecture.md — library module map, design principles
- docs/drive-access.md — drive sessions, SCSI transport, unlock

Code cleanup:
- Zero compiler warnings
- Removed all debug eprintln from library code
- No hardcoded private paths — KEYDB tests use KEYDB_PATH env var
- KEYDB search locations as named constants
- drive.rs: extracted create_platform(), deduplicated open methods
- lib.rs: updated doc examples to show Disc::scan() API
- Fixed UDF file reads (partition_start, not metadata_start)
- Exported KeySource from disc module
2026-04-07 12:12:24 -07:00
MattJackson 721308cb8e Fix UDF file data reads: use partition_start not metadata_start
File data extents are in the physical partition (partition_start + lba),
not the metadata partition. ICBs (directory/file entries) are in metadata,
but the data they point to is in physical. This one-line fix makes all
MPLS playlists readable — previously only 1/18 had correct MPLS magic.

Tested on Civil War UHD: 18 playlists, main movie at 00400.mpls (57min, 6 clips).
2026-04-07 11:44:23 -07:00
MattJackson cc58ab1754 AACS graceful fallback: handshake optional, disc-hash-only KEYDB path
- SCSI handshake now optional: if drive doesn't support AACS layer
 (e.g. raw-mode drives where AACS feature current=0), falls back
 to disc-hash-only KEYDB lookup
- open_no_unlock(): open drive without entering raw mode
- device_path tracking on DriveSession
- Real drive test: BU40N with Civil War UHD
 - Handshake correctly fails (05/6F/01 key not present — expected)
 - Scan continues, finds AACS directory, reads Unit_Key_RO.inf
 - KEYDB lookup attempted (disc hash matching WIP)
2026-04-07 11:41:34 -07:00
MattJackson 193b571967 Expose full AACS state: version, key source, disc hash, all keys public
- AacsState now exposes: version (1/2), bus_encryption, mkb_version,
 disc_hash, key_source (KeyDb/KeyDbDerived/ProcessingKey/DeviceKey),
 vuk, unit_keys, read_data_key, volume_id
- All keys are public — apps that contribute to KEYDB ecosystem can
 read VUK, unit keys, Volume ID, disc hash for database submission
- resolve_keys reports which path succeeded (key_source 1-4)
- Cleaned up resolve_keys with shared builder to reduce duplication
2026-04-07 11:25:04 -07:00
MattJackson bac2e39ee5 AACS 100%: device key tree, MKB SCSI read, AACS2 detection, STN streams
- Device key subset-difference tree: aesg3 key derivation, v_mask calc,
 tree traversal from device key to processing key to media key
- MKB SCSI read: REPORT DISC STRUCTURE 0x83 with multi-pack support
- resolve_keys now has 4 paths:
 1. disc hash → KEYDB → VUK
 2. KEYDB media key + VID → VUK
 3. MKB + processing keys → media key → VUK
 4. MKB + device keys → processing key → media key → VUK
- setup_aacs reads MKB from drive (not just from file)
- AACS 2.0 detection: drive cert type 0x11 detected, falls back to
 AACS 1.0 handshake (P-256 crypto path prepared but not yet built)
- STN table parsing in mpls.rs: video format/rate, audio format/rate/lang,
 subtitle lang, coding type — all streamed into Disc title streams
- 31 tests passing
2026-04-07 11:19:34 -07:00
MattJackson 9825db82aa AACS complete: Unit_Key_RO.inf parser, disc hash, MKB processing, resolve chain
- Unit_Key_RO.inf: proper parser matching libaacs format (uk_pos, stride,
 AACS1 48-byte / AACS2 64-byte key spacing, title→CPS unit mapping)
- Disc hash: SHA1 of Unit_Key_RO.inf for KEYDB lookup
- MKB processing: processing keys + subdiff records + cvalues → media key
 (verify media key record with 12-byte zero check)
- Content Certificate parser: detect AACS version + bus encryption flag
- resolve_keys(): full 3-path chain:
 1. disc hash → KEYDB → VUK (fast, 99% of discs)
 2. KEYDB media key + VID → VUK
 3. MKB + processing keys → media key → VUK
- setup_aacs() now reads Unit_Key_RO.inf + Content Cert from disc via UDF
- 31 tests passing
2026-04-07 11:13:20 -07:00
MattJackson 985d00f0a4 AACS 2.0 full pipeline: handshake, bus key, decrypt, transparent API
- aacs.rs: content decryption (AES-CBC aligned units, bus decrypt, VUK
 derivation, unit key decrypt). 11 tests including synthetic roundtrip.
- aacs_handshake.rs: SCSI authentication (ECDH on AACS 160-bit curve,
 ECDSA sign/verify, AES-CMAC, bus key derivation, read_data_key).
 14 tests including EC order, ECDH shared secret, cert verification.
- disc.rs: transparent API — Disc::scan() detects AACS, authenticates,
 derives keys internally. ContentReader decrypts on the fly. App never
 sees AACS details.
- error.rs: AacsError (E7000) variant

BF v12 complete: 284 unique matches from 8 AWS instances.
2026-04-06 20:40:52 -07:00
MattJackson dbc5789406 Add AACS module: KEYDB.cfg parser, VUK lookup, 180K disc entries in <2s
Parses device keys, processing keys, host cert, per-disc VUKs.
Full KEYDB.cfg (60MB, 180K entries) parsed in 1.85 seconds.
All test discs found with VUKs.

Next: disc hash computation, title key decryption, content decryption.
2026-04-06 18:49:58 -07:00
MattJackson eb3b84216a Reduce read_disc timeout to 5s (was 30s)
Prevents indefinite hangs on encrypted/unreadable sectors.
Some discs put MPLS/CLPI/JAR data in AACS-encrypted regions
that block reads without proper AACS authentication.
2026-04-06 18:22:43 -07:00
MattJackson a07a7aebf2 Always unlock on DriveSession::open()
Silently ignores unlock failures (unencrypted discs).
Fixes disc-info on discs that gate reads behind AACS auth.
2026-04-06 17:06:32 -07:00
MattJackson 37ee573014 Add JAR parser: extract audio/subtitle labels from BD-J menus
Scans BDMV/JAR/*.jar class files for track label patterns:
 eng_MLP_ → TrueHD, eng_ADES_US_ → Descriptive Audio (US)
 dan_PGStream4 → Danish subtitles

Solves the "3x AC-3 English" problem — we identify Descriptive Audio, commentary, compatibility tracks.

Future: set MKV "visual impaired" flag automatically during mux.

Local only — needs more disc samples to validate before publishing.
2026-04-06 16:09:30 -07:00
MattJackson b34b7dd2e6 Improve API docs, add docs.rs badge + link 2026-04-06 15:58:32 -07:00
36 changed files with 7083 additions and 846 deletions
+36
View File
@@ -0,0 +1,36 @@
# Changelog
## 0.4.0 (2026-04-07)
### Labels — complete rewrite
- **Detect-then-parse architecture**: each BD-J authoring format has its own parser module with `detect()` and `parse()` functions. Drop in a new parser with one line in the registry.
- **5 format parsers**: Paramount (`playlists.xml`), Criterion (`streamproperties.xml`), Pixelogic (`bluray_project.bin`), Warner CTRM (`menu_base.prop` / `language_streams.txt`), shared label vocabulary (`vocab.rs`)
- **Raw disc data principle**: label data passes through as-is from disc. Only BD-standard codec identifiers (MLP, AC3, DTS) are mapped to display names. Unknown authoring tool codes (csp, eda, cf) pass through raw.
- **`variant` field**: replaces `region` — language dialect codes from authoring tools, not BD spec regions
- Removed: old `jar` module (superseded by labels)
- Removed: dead label apply functions from disc.rs
### Drive
- **`DriveSession::eject()`**: sends PREVENT ALLOW MEDIUM REMOVAL then START STOP UNIT. Works reliably after raw mode unlock.
- **`DiscRegion` enum**: Free, BluRay(A/B/C), Dvd(1-8). UHD always region-free.
### Capture
- **Fixed sector range collection**: captures ALL files on disc (only skips STREAM/ video files and >50MB). Previously skipped BACKUP/, DUPLICATE/, and files >10MB which missed JAR content.
## 0.3.1
- Labels module: 4 disc file parsers for stream labels
- Simplified labels API
## 0.3.0
- Initial public release
- SCSI transport (Linux SG_IO, macOS IOKit)
- UDF 2.50 filesystem reader
- MPLS/CLPI parsers with full STN support
- Drive identification + profile matching
- 206 bundled drive profiles
- AACS 1.0 decryption (VUK + unit keys)
+9 -7
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.3.0"
version = "0.4.1"
edition = "2021"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
@@ -15,14 +15,16 @@ sha1 = "0.10"
aes = "0.8"
cbc = "0.1"
flate2 = "1"
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
[[bin]]
name = "freemkv-info"
path = "src/bin/freemkv_info.rs"
[target.'cfg(target_os = "macos")'.dependencies]
libc = "0.2"
[[bin]]
name = "freemkv-test"
path = "src/bin/freemkv_test.rs"
+57 -84
View File
@@ -1,10 +1,12 @@
[![Crates.io](https://img.shields.io/crates/v/libfreemkv)](https://crates.io/crates/libfreemkv)
[![docs.rs](https://img.shields.io/docsrs/libfreemkv)](https://docs.rs/libfreemkv)
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![Drives: 206](https://img.shields.io/badge/drives-206-brightgreen)]()
# libfreemkv
Rust library for raw sector access on optical drives. Identifies drives using standard SCSI commands, matches them against 206 bundled profiles, and unlocks raw read mode. No external files, no configuration.
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, AACS decryption, and content reading in one crate. Bundled drive profiles — no external files needed.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
Part of the [freemkv](https://github.com/freemkv) project.
@@ -12,113 +14,84 @@ Part of the [freemkv](https://github.com/freemkv) project.
```toml
[dependencies]
libfreemkv = "0.2"
libfreemkv = "0.4"
```
## Quick Start
```rust
use libfreemkv::DriveSession;
use libfreemkv::{DriveSession, Disc, ScanOptions};
use std::path::Path;
// Open drive — profiles are bundled, auto-identified
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
session.unlock()?; // activate raw read mode
session.calibrate()?; // optimize read speed
// Scan disc — UDF, playlists, streams, AACS (all automatic)
let disc = Disc::scan(&mut session, &ScanOptions::default())?;
let mut buf = vec![0u8; 2048];
let n = session.read_sectors(0, 1, &mut buf)?;
```
for title in &disc.titles {
println!("{} — {} streams", title.duration_display(), title.streams.len());
}
## How It Works
1. **INQUIRY** (SPC-4 §6.4) — reads vendor_id, product_id, product_revision, vendor_specific
2. **GET CONFIGURATION 010C** (MMC-6 §5.3.10) — reads firmware_date
3. **Profile match** — five fields uniquely identify the drive against 206 bundled profiles
4. **READ BUFFER** — single command with per-drive mode/buffer_id activates raw read mode
5. **Signature verify** — drive responds with 4-byte signature + "MMkv" confirmation
No fingerprints. No encrypted lookups. All identification uses standard SCSI fields.
## API
```rust
let mut session = DriveSession::open(device_path)?;
// Drive identity
session.drive_id.vendor_id // "HL-DT-ST"
session.drive_id.product_id // "BD-RE BU40N"
session.drive_id.product_revision // "1.03"
session.drive_id.vendor_specific // "NM00000"
session.drive_id.firmware_date // "211810241934"
// Profile data
session.profile.chipset // Chipset::MediaTek
session.profile.unlock_mode // 0x01
session.profile.unlock_buf_id // 0x44
session.profile.signature // [0x99, 0x9e, 0xc3, 0x75]
// Operations
session.unlock()?;
session.calibrate()?;
session.read_sectors(lba, count, &mut buf)?;
session.status()?;
session.read_config()?;
session.read_register(index)?;
```
## Chipset Support
| Chipset | Status | Drives | Brands |
|---------|--------|--------|--------|
| MediaTek MT1959 | Supported | 206 | LG, ASUS, HP |
| Renesas | Planned | -- | Pioneer |
## Drive Profile
Each bundled profile (compiled into the binary):
```json
{
"vendor_id": "HL-DT-ST",
"product_id": "BD-RE BU40N ",
"product_revision": "1.03",
"vendor_specific": "NM00000",
"firmware_date": "211810241934",
"chipset": "mediatek",
"unlock_mode": 1,
"unlock_buf_id": 68,
"signature": "999ec375",
"register_offsets": ["10e291", "11ab1c"]
// Read content (decrypted transparently if AACS keys available)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
// 6144 bytes of content per aligned unit
}
```
## What It Does
- **Drive access** — open, identify, unlock for raw reads
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info, BD-J labels
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0)
- **Content reading** — sector reads with automatic decryption
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
├── ScsiTransport — SG_IO (Linux), IOKit (macOS)
├── DriveProfile — per-drive unlock parameters (bundled)
└── Platform — MediaTek (supported), Renesas (planned)
Disc — scan titles, streams, AACS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
├── MPLS parser — playlists → titles + clips + streams
├── CLPI parser — clip info → EP map → sector extents
├── JAR parser — BD-J audio track labels
└── AACS — key resolution + content decryption
```
See [docs/](docs/) for detailed technical documentation on each module.
## Error Codes
| Code | Error | Meaning |
|------|-------|---------|
| E1000 | DeviceNotFound | Device path doesn't exist |
| E1001 | DevicePermission | No access (try sudo or cdrom group) |
| E2000 | UnsupportedDrive | No matching profile |
| E3000 | UnlockFailed | Unlock command rejected |
| E3001 | SignatureMismatch | Response signature wrong |
| E3002 | NotUnlocked | Operation requires unlock first |
| E4000 | ScsiError | SCSI command failed |
| E5000 | IoError | System I/O error |
All errors are structured with numeric codes. No user-facing English text — applications format their own messages.
## Platform
| Range | Category |
|-------|----------|
| E1xxx | Device errors (not found, permission) |
| E2xxx | Profile errors (unsupported drive) |
| E3xxx | Unlock errors (failed, signature) |
| E4xxx | SCSI errors (command failed, timeout) |
| E5xxx | I/O errors |
| E6xxx | Disc format errors |
| E7xxx | AACS errors |
## Platform Support
| Platform | Status | Backend |
|----------|--------|---------|
| Linux | Supported | SG_IO ioctl |
| macOS | Planned | IOKit |
| macOS | Supported | IOKit SCSITask |
| Windows | Planned | SPTI |
The `ScsiTransport` trait abstracts the platform. Adding a backend is one file behind a `cfg` gate.
## Contributing
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to submit your drive's profile.
Run `freemkv info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
## License
+7
View File
@@ -0,0 +1,7 @@
fn main() {
#[cfg(target_os = "macos")]
{
println!("cargo:rustc-link-lib=framework=IOKit");
println!("cargo:rustc-link-lib=framework=CoreFoundation");
}
}
+32
View File
@@ -0,0 +1,32 @@
# libfreemkv Documentation
Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv), the open source optical drive library.
## Start Here
**[Disc to Rip: End-to-End Flow](disc-to-rip.md)** — How the library goes from a disc in the drive to decrypted content. Read this first.
## Reference
| Document | What it covers |
|----------|---------------|
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
| [Drive Access](drive-access.md) | DriveSession, SCSI transport, profiles, unlock, why raw mode is needed |
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
| [CLPI Clip Info](clpi.md) | EP map (coarse + fine entries), timestamp-to-sector mapping, extent calculation |
## Reading Order
If you want to understand the whole library:
1. **[Disc to Rip](disc-to-rip.md)** — the big picture
2. **[Architecture](architecture.md)** — how modules fit together
3. **[Drive Access](drive-access.md)** — how we talk to hardware
4. **[UDF](udf.md)** → **[MPLS](mpls.md)** → **[CLPI](clpi.md)** — how disc content is structured
5. **[AACS](aacs.md)** — how encryption works and how we break it
## API Documentation
Generated API docs are on [docs.rs/libfreemkv](https://docs.rs/libfreemkv).
+320
View File
@@ -0,0 +1,320 @@
# AACS Encryption Support
## Overview
AACS (Advanced Access Content System) is the encryption layer used by Blu-ray and UHD 4K discs to protect content. libfreemkv implements AACS decryption to enable transparent disc access.
There are two major versions:
- **AACS 1.0** -- Used by standard Blu-ray discs. Relies on a custom 160-bit elliptic curve for bus authentication and AES-128 for content encryption. Processing keys and device keys can derive the media key from the disc's Media Key Block (MKB).
- **AACS 2.0** -- Used by UHD 4K Blu-ray discs. Adds a per-sector bus encryption layer (read_data_key) on top of the standard content encryption. Uses P-256/SHA-256 for its native handshake, though drives accept AACS 1.0 host certificates for backward compatibility.
Both versions use AES-128-CBC for content decryption with a fixed initialization vector. The fundamental key hierarchy is the same: a Volume Unique Key (VUK) decrypts per-title unit keys, which in turn decrypt the content stream.
## Architecture
AACS support is split across two modules:
### `aacs.rs` -- Keys and Decryption
Handles everything related to key resolution and content decryption:
- KEYDB.cfg parsing (device keys, processing keys, host certificates, per-disc entries)
- Disc hash computation (SHA-1 of `Unit_Key_RO.inf`)
- VUK resolution chain (4 paths, described below)
- MKB record parsing and media key derivation
- Subset-difference tree traversal (AACS-G3 key derivation)
- Unit_Key_RO.inf parsing and unit key decryption
- Content Certificate parsing (AACS version detection)
- Aligned unit decryption (AES-128-CBC)
- Bus decryption (AACS 2.0 read_data_key layer)
### `aacs_handshake.rs` -- SCSI Authentication
Handles the drive-level SCSI authentication protocol:
- ECDH key agreement on the AACS 160-bit curve
- ECDSA signing and verification
- Bus key derivation
- AGID management (allocate/invalidate)
- Volume ID retrieval (encrypted with bus key, verified by AES-CMAC)
- Read Data Key retrieval (for AACS 2.0 bus decryption)
- AACS LA public key certificate verification
## Key Resolution Chain
When a disc is scanned, `resolve_keys()` attempts four paths in priority order. The first path that succeeds is used.
### Path 1: KEYDB VUK Lookup (fastest)
```
Unit_Key_RO.inf --> SHA-1 --> disc_hash --> KEYDB lookup --> VUK
```
The disc hash is computed as the SHA-1 digest of the raw `Unit_Key_RO.inf` file from the disc's `/AACS/` directory. This hash is used as the lookup key in `KEYDB.cfg`. If a matching entry contains a VUK (`V` field), it is used directly.
This is the fast path and resolves the vast majority of discs in a well-maintained KEYDB.
### Path 2: KEYDB Media Key + Volume ID
```
KEYDB media_key + Volume ID (from SCSI handshake) --> VUK derivation
```
If the disc hash is not in the KEYDB but a KEYDB entry has a matching Volume ID (`I` field) and a media key (`M` field), the VUK is derived:
```
VUK = AES-128-ECB-DECRYPT(media_key, volume_id) XOR volume_id
```
Requires a successful SCSI handshake to obtain the Volume ID.
### Path 3: MKB + Processing Keys
```
MKB (from disc) + processing_keys (from KEYDB) --> media_key --> VUK
```
Processing keys are pre-computed keys that work against specific MKB versions. For each processing key, the library:
1. Parses the MKB to extract the Verify Media Key Record (`mk_dv`), subset-difference index, and conditional values (cvalues).
2. Tries each processing key against each UV/cvalue pair: `mk = AES-DEC(pk, cvalue) XOR cvalue`.
3. Validates the derived media key: `AES-ECB(mk, mk_dv)` must produce 12 leading zero bytes.
4. Derives VUK from the validated media key and Volume ID.
### Path 4: MKB + Device Keys (Subset-Difference Tree)
```
MKB + device_keys --> subset-difference tree traversal --> processing_key --> media_key --> VUK
```
The most complex path. Each device key has an associated node number, UV value, and mask parameters that position it in the AACS subset-difference tree. The library:
1. Finds the subset-difference entry in the MKB that applies to the device key's node.
2. Traverses the tree using AACS-G3 key derivation: `aesg3(key, inc) = AES-DEC(key, seed) XOR seed`, where `seed[15]` is incremented by `inc`. Each tree node produces a left child (inc=0), a processing key (inc=1), and a right child (inc=2).
3. At each level, selects left or right based on the UV bit at the current position.
4. The resulting processing key is validated against the MKB cvalue to derive the media key.
5. VUK is derived from the media key and Volume ID.
## Content Decryption
### Aligned Units
AACS encrypts content in aligned units of 6144 bytes (3 sectors of 2048 bytes each). The encryption flag is signaled by the copy_permission_indicator bits in byte 0 of the unit (`unit[0] & 0xC0 != 0`).
### Per-Unit Key Derivation
Each aligned unit has its own decryption key derived from the CPS unit key:
1. **Derive**: AES-128-ECB encrypt the first 16 bytes of the unit (plaintext TP_extra_header) with the unit key.
2. **XOR**: XOR the encrypted result with the original 16 bytes to produce the per-unit decryption key.
3. **Decrypt**: AES-128-CBC decrypt bytes 16 through 6143 using the per-unit key and the fixed AACS IV.
4. **Clear flag**: Clear the encryption indicator bits (`unit[0] &= !0xC0`).
### Fixed IV
All AES-CBC operations in AACS use the same fixed initialization vector, defined in the AACS specification.
### Verification
After decryption, the library verifies correctness by checking for MPEG-TS sync bytes (0x47) at the expected 192-byte packet boundaries within the unit. Blu-ray transport stream packets are 192 bytes: 4-byte TP_extra_header followed by a 188-byte TS packet.
## Bus Encryption
### AACS 1.0
Standard Blu-ray discs do not use bus encryption. Content is read directly from the disc and decrypted using the unit key.
### AACS 2.0
UHD 4K discs add a per-sector bus encryption layer. The drive encrypts data as it is read from the disc, and the host must decrypt it before applying AACS content decryption.
Bus encryption uses a **read_data_key** obtained during the SCSI handshake. For each 2048-byte sector within an aligned unit, bytes 16 through 2047 are AES-128-CBC encrypted with the read_data_key and the fixed AACS IV. The first 16 bytes of each sector remain plaintext.
The full decryption pipeline for AACS 2.0:
1. **Bus decrypt**: For each sector, AES-128-CBC decrypt bytes 16..2047 with the read_data_key.
2. **Content decrypt**: Standard per-unit key derivation and AES-128-CBC decryption as described above.
## SCSI Handshake
The AACS SCSI authentication handshake establishes a shared bus key between host and drive, then uses it to securely transfer the Volume ID and read data keys.
### Protocol Flow
1. **Invalidate AGIDs**: Send REPORT KEY with format 0x3F for AGIDs 0-3 to clear stale sessions.
2. **Allocate AGID**: REPORT KEY format 0x00 returns a fresh Authentication Grant ID.
3. **Send host credentials**: SEND KEY format 0x01 transmits the host nonce (20 random bytes) and host certificate (92 bytes).
4. **Receive drive credentials**: REPORT KEY format 0x01 returns the drive nonce and drive certificate.
5. **Receive drive key**: REPORT KEY format 0x02 returns the drive's ephemeral EC key point and ECDSA signature over `host_nonce || drive_key_point`.
6. **Verify drive key**: The signature is verified against the drive's public key (extracted from its certificate). AACS 1.0 certificates are verified against the AACS LA public key.
7. **Send host key**: The host generates an ephemeral key pair, signs `drive_nonce || host_key_point` with the host private key, and sends via SEND KEY format 0x02.
8. **Compute bus key**: ECDH shared secret = `host_private_key * drive_key_point`. The bus key is the low 128 bits of the shared point's x-coordinate.
### Post-Authentication Reads
- **Volume ID**: REPORT DISC STRUCTURE format 0x80. Returns 16-byte VID encrypted with the bus key, plus an AES-CMAC MAC for integrity verification.
- **Read Data Keys**: REPORT DISC STRUCTURE format 0x84. Returns the read_data_key and write_data_key, each AES-ECB encrypted with the bus key.
### Elliptic Curve
AACS 1.0 uses a custom 160-bit Weierstrass curve (`y^2 = x^3 + ax + b mod p`) with 20-byte field elements. The library implements full EC arithmetic: point addition, doubling, scalar multiplication, modular inverse, ECDSA sign/verify, and ECDH key agreement.
## AACS 2.0 Status
AACS 2.0 discs are detected via the Content Certificate file (`Content000.cer` or `Content001.cer`). A certificate type byte of 0x01 indicates AACS 2.0.
AACS 2.0 drives are identified by their drive certificate type (0x11). These drives natively use P-256/SHA-256, but accept AACS 1.0 host certificates for backward compatibility.
Current implementation status:
- AACS 2.0 detection: **implemented** (Content Certificate parsing, drive cert type check)
- AACS 1.0 handshake with AACS 2.0 drives: **implemented** (backward compatibility mode)
- Full P-256 AACS 2.0 handshake: **not yet implemented** (prepared but rarely needed since drives accept AACS 1.0 host certs)
- Bus decryption with read_data_key: **implemented**
- Content decryption: **implemented** (same as AACS 1.0)
In practice, AACS 2.0 UHD discs work through the backward-compatible AACS 1.0 handshake path, with the addition of read_data_key bus decryption.
## API Usage
AACS decryption is transparent to the application. The `Disc::scan()` method handles everything automatically:
```rust
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();
// Check encryption state
if disc.encrypted {
if let Some(ref aacs) = disc.aacs {
println!("AACS {}.0", aacs.version);
println!("Key source: {}", aacs.key_source.name());
println!("Disc hash: {}", aacs.disc_hash);
if let Some(mkb_ver) = aacs.mkb_version {
println!("MKB version: {}", mkb_ver);
}
} else {
println!("Encrypted but keys not available");
}
}
// Read content -- decryption is automatic
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
}
```
The application never touches keys, never calls decryption functions, and never manages handshakes. All of that is internal to `Disc::scan()` and `ContentReader::read_unit()`.
### KEYDB Location
`ScanOptions` controls where the KEYDB is loaded from. If no explicit path is set, the library checks:
1. `~/.config/aacs/KEYDB.cfg`
2. `/etc/aacs/KEYDB.cfg`
To specify an explicit path:
```rust
let opts = ScanOptions::with_keydb("/path/to/KEYDB.cfg");
let disc = Disc::scan(&mut session, &opts).unwrap();
```
### AacsState
After a successful scan, `disc.aacs` contains an `AacsState` with:
| Field | Type | Description |
|-------|------|-------------|
| `version` | `u8` | AACS version (1 or 2) |
| `bus_encryption` | `bool` | Whether bus encryption is active |
| `mkb_version` | `Option<u32>` | MKB version from disc |
| `disc_hash` | `String` | SHA-1 of Unit_Key_RO.inf (hex with 0x prefix) |
| `key_source` | `KeySource` | How keys were resolved |
| `vuk` | `[u8; 16]` | Volume Unique Key |
| `unit_keys` | `Vec<(u32, [u8; 16])>` | Decrypted unit keys (CPS unit number, key) |
| `read_data_key` | `Option<[u8; 16]>` | AACS 2.0 bus decryption key |
| `volume_id` | `[u8; 16]` | Volume ID from SCSI handshake |
### KeySource
| Variant | Description |
|---------|-------------|
| `KeyDb` | VUK found directly in KEYDB by disc hash |
| `KeyDbDerived` | Media key + Volume ID from KEYDB, VUK derived |
| `ProcessingKey` | MKB + processing keys from KEYDB |
| `DeviceKey` | MKB + device keys, subset-difference tree traversal |
## KEYDB.cfg Format Reference
The KEYDB.cfg file contains all cryptographic material needed for AACS decryption. Lines starting with `;` or `#` are comments.
### Device Keys
```
| DK | DEVICE_KEY 0x<key> | DEVICE_NODE 0x<node> | KEY_UV 0x<uv> | KEY_U_MASK_SHIFT 0x<shift>
```
- `key`: 16-byte AES device key (hex)
- `node`: Device node number in the subset-difference tree (hex)
- `uv`: UV value for tree positioning (hex)
- `shift`: U mask shift value (hex)
### Processing Keys
```
| PK | 0x<key>
```
- `key`: 16-byte pre-computed processing key (hex)
### Host Certificate
```
| HC | HOST_PRIV_KEY 0x<privkey> | HOST_CERT 0x<cert>
```
- `privkey`: 20-byte ECDSA private key (hex)
- `cert`: 92-byte AACS host certificate (hex)
The host certificate is used for SCSI authentication. It contains the host's public key and is signed by the AACS Licensing Administrator.
### Disc Entries
```
0x<disc_hash> = <title> | D | <date> | M | 0x<media_key> | I | 0x<disc_id> | V | 0x<vuk> | U | <unit_keys>
```
- `disc_hash`: 20-byte SHA-1 of Unit_Key_RO.inf (hex)
- `title`: Human-readable disc title
- `D`: Date tag, followed by release/rip date
- `M`: Media key tag, followed by 16-byte media key (hex)
- `I`: Disc ID tag, followed by 16-byte Volume ID (hex)
- `V`: VUK tag, followed by 16-byte Volume Unique Key (hex)
- `U`: Unit keys tag, followed by space-separated `<unit_num>-0x<key>` pairs
All fields after the title are optional. A minimal entry needs only the disc hash and VUK:
```
0x<disc_hash> = <title> | V | 0x<vuk>
```
Inline comments are supported with `;`:
```
0x<disc_hash> = <title> | V | 0x<vuk> ; MKBv77
```
+180
View File
@@ -0,0 +1,180 @@
# libfreemkv Architecture
Open source optical drive access library for 4K UHD Blu-ray, Blu-ray, and DVD.
Rust library with no external dependencies at runtime -- profiles are bundled,
AACS keys are derived internally, and all SCSI communication is handled in-process.
**Repository:** <https://github.com/freemkv/libfreemkv>
**License:** AGPL-3.0-only
---
## Design Principles
1. **CLI is dumb.** All drive communication, disc parsing, AACS decryption, and
format handling live in the library. CLI binaries are thin wrappers that call
`DriveSession::open()` and `Disc::scan()`.
2. **No external files.** 206 drive profiles are compiled into the binary via
`include_str!`. No configuration directory, no runtime file lookups for drive
support.
3. **Transparent AACS.** The `ContentReader` decrypts on the fly when keys are
available. Callers read cleartext sectors without knowing whether the disc
was encrypted.
4. **Structured errors, no English.** Every error has a numeric code (E1000-E7000).
The library never formats user-facing messages -- applications do that.
5. **Library-agnostic.** No concept of "supported" vs "unsupported" drives at a
policy level. If a profile exists, the library uses it.
---
## Module Map
```
libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive DriveSession — open, identify, unlock, read
│ ├── scsi ScsiTransport trait + SG_IO implementation
│ ├── platform/ Platform trait — per-chipset command handlers
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, hp)
│ ├── profile DriveProfile loading, matching, bundled JSON
│ ├── identity DriveId from INQUIRY + GET_CONFIG 010C
│ └── speed DriveSpeed enum, SET CD SPEED CDB builder
│
├── Disc Scanning
│ ├── disc Disc::scan() — titles, streams, extents, AACS setup
│ ├── udf UDF 2.50 filesystem reader (metadata partitions)
│ ├── mpls MPLS playlist parser — clips, streams, STN table
│ ├── clpi CLPI clip info parser — EP map, sector extents
│ └── jar BD-J JAR label extraction (audio/subtitle names)
│
├── Encryption
│ ├── aacs KEYDB parsing, VUK lookup, MKB processing, unit decryption
│ └── aacs_handshake ECDH bus authentication, Volume ID, Read Data Key
│
└── error Error enum with numeric codes E1000-E7000
```
---
## Drive Access Flow
```
DriveSession::open("/dev/sr0")
│
├─ scsi::open() Open /dev/sr0 via SG_IO
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
├─ profile::find_by_drive_id() Match against 206 bundled profiles
├─ Platform::new() Instantiate chipset driver (Mt1959)
└─ Platform::unlock() Activate raw disc access mode
```
After open, the session provides:
- `read_sectors(lba, count, buf)` -- raw sector reads (through platform driver)
- `read_disc(lba, count, buf)` -- standard READ(10) for filesystem data
- `scsi_execute(cdb, dir, buf, timeout)` -- arbitrary SCSI commands
- `status()`, `calibrate()`, `read_config()`, `read_register()`
---
## Disc Scanning Flow
```
Disc::scan(&mut session, &ScanOptions)
│
├─ READ CAPACITY Get disc size in sectors
├─ udf::read_filesystem() Parse UDF 2.50 (AVDP → VDS → metadata → FSD → root)
├─ For each BDMV/PLAYLIST/*.mpls:
│ ├─ mpls::parse() Extract play items, STN streams
│ └─ For each clip:
│ └─ clpi::parse() EP map → sector extents for the clip's time range
├─ Detect AACS Check for /AACS directory on disc
└─ Disc::setup_aacs() Handshake + KEYDB → VUK → unit keys (if encrypted)
```
The result is a `Disc` with:
- `titles: Vec<Title>` -- sorted by duration, each with streams and sector extents
- `aacs: Option<AacsState>` -- decryption keys if available
- `encrypted: bool` -- whether the disc uses AACS
---
## AACS Decryption
Four key resolution paths, tried in order:
| Path | Method | Speed |
|------|--------|-------|
| 1 | VUK lookup by disc hash in KEYDB.cfg | Instant |
| 2 | Media Key + Volume ID from KEYDB → derive VUK | Fast |
| 3 | Processing Keys + MKB → Media Key → VUK | Medium |
| 4 | Device Keys + MKB subset-difference tree → VUK | Slow |
The AACS handshake (`aacs_handshake`) performs ECDH key agreement over the
AACS 1.0 160-bit elliptic curve to obtain:
- **Volume ID** -- needed for VUK derivation (paths 2-4)
- **Read Data Key** -- needed for AACS 2.0 (UHD) bus decryption
Content decryption uses AES-128-CBC on 6144-byte aligned units. The
`ContentReader` handles this transparently.
---
## Error Codes
All errors carry a numeric code for programmatic handling. No user-facing text
is baked into the library.
| Range | Category | Examples |
|-------|----------|----------|
| E1xxx | Device errors | `DeviceNotFound`, `DevicePermission` |
| E2xxx | Profile errors | `UnsupportedDrive`, `ProfileNotFound`, `ProfileParse` |
| E3xxx | Unlock errors | `UnlockFailed`, `SignatureMismatch`, `NotUnlocked`, `NotCalibrated` |
| E4xxx | SCSI errors | `ScsiError`, `ScsiTimeout` |
| E5xxx | I/O errors | `IoError` (wraps `std::io::Error`) |
| E6xxx | Disc format errors | `DiscError` (UDF, MPLS, CLPI parse failures) |
| E7xxx | AACS errors | `AacsError` (key resolution, handshake, decryption) |
---
## Platform Support
| Platform | Transport | Status |
|----------|-----------|--------|
| Linux | SG_IO ioctl on `/dev/sr*` | Implemented |
| macOS | IOKit SCSI passthrough | Planned |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Planned |
The `ScsiTransport` trait abstracts the platform. Adding a new platform requires
implementing `execute()` for that OS and wiring it into `scsi::open()`.
---
## Chipset Support
| Chipset | Drives | Status |
|---------|--------|--------|
| MediaTek MT1959 | LG, ASUS, hp | Implemented (206 profiles) |
| Renesas RS8xxx/RS9xxx | Pioneer, some HL-DT-ST | Planned |
The `Platform` trait abstracts chipset-specific commands. Each chipset implements
10 handlers (unlock, config, register, calibrate, keepalive, status, probe,
read_sectors, timing). All handlers are accessed via SCSI READ BUFFER with
chipset-specific mode and buffer ID bytes.
---
## Build
```
cargo build --release
```
Linux builds produce a static library and two binaries (`freemkv-info`,
`freemkv-test`). The `libc` dependency is Linux-only. On non-Linux platforms,
the library compiles but `scsi::open()` returns a platform-not-supported error
until the IOKit/SPTI backends are implemented.
+197
View File
@@ -0,0 +1,197 @@
# CLPI Clip Information Format
## What is CLPI?
CLPI (Clip Information) files describe the structure of individual M2TS transport streams on a Blu-ray disc. Each `.clpi` file in `BDMV/CLIPINF/` corresponds to one `.m2ts` file in `BDMV/STREAM/` with the same numeric name (e.g. `00001.clpi` describes `00001.m2ts`).
The most important data in a CLPI file is the **EP (Entry Point) map**, which provides timestamp-to-sector mapping. This is essential for seeking and for extracting the specific sector ranges that correspond to a playlist's in/out time window.
## File Structure
CLPI files use big-endian byte order. The header:
```
Offset Size Field
------ ---- -----
0 4 Magic: "HDMV"
4 4 Version: "0200" (BD) or "0300" (UHD BD)
8 4 SequenceInfo start offset
12 4 ProgramInfo start offset
16 4 CPI (Characteristic Point Information) start offset
20 4 ClipMark start offset
24 4 ExtensionData start offset
```
### ClipInfo Section (offset 40)
Contains basic clip metadata. The source packet count (total number of 192-byte source packets in the M2TS file) is at offset 56:
```
Offset Size Field
------ ---- -----
40 4 ClipInfo length
44 2 Reserved
46 1 Stream type
47 1 Application type
48 4 Reserved
52 4 TS recording rate
56 4 Source packet count
```
The source packet count multiplied by 192 gives the total byte size of the M2TS file.
## EP Map
The EP map lives inside the CPI section and provides random access into the transport stream. It maps PTS timestamps to Source Packet Numbers (SPN), enabling precise seeking without scanning the stream.
### CPI Section Layout
```
Offset Size Field
------ ---- -----
0 4 CPI length
4 2 Reserved / CPI type
6 ... EP map
```
### EP Map Header
```
Offset Size Field
------ ---- -----
0 1 Reserved
1 1 Number of stream PID entries
2 ... Stream PID entry headers (one per stream)
```
Each stream PID entry header (14 bytes):
```
Offset Size Field
------ ---- -----
0 2 Stream PID
2 2 Reserved + EP stream type
4 2 Number of coarse entries
6 4 Number of fine entries (note: 32-bit, can be large)
10 4 EP map start offset (relative to EP map start)
```
libfreemkv parses only the first stream (primary video), which is sufficient for sector-level seeking.
### Two-Level Index
The EP map uses a two-level structure to compress what would otherwise be a very large lookup table:
- **Coarse entries**: low-resolution index covering large time/sector ranges
- **Fine entries**: high-resolution entries within each coarse range
Each coarse entry points to a range of fine entries via `ref_to_fine_id`.
### Coarse Entries (8 bytes each)
Located immediately after the fine table start offset (4 bytes) in the per-stream EP map:
```
Bits Field
------ -----
[31:14] ref_to_fine_id (18 bits) -- index of first fine entry in this range
[13:0] pts_coarse (14 bits) -- upper bits of PTS
```
Second dword:
```
Bits Field
------ -----
[31:0] spn_coarse (32 bits) -- upper bits of SPN
```
### Fine Entries (4 bytes each)
Located at the fine table start offset within the per-stream EP map:
```
Bits Field
------ -----
[31] is_angle_change_point (1 bit)
[30:28] I_end_position_offset (3 bits)
[27:17] pts_fine (11 bits) -- lower bits of PTS
[16:0] spn_fine (17 bits) -- lower bits of SPN
```
## Reconstructing Full PTS and SPN
The full timestamp and packet number are assembled by combining the coarse and fine components:
### Full PTS
```
full_pts = (pts_coarse << 19) + (pts_fine << 8)
```
The coarse component provides the upper bits, shifted left by 19. The fine component provides mid-range bits, shifted left by 8. The resulting PTS is in 45kHz ticks, matching the MPLS timestamp format.
### Full SPN
```
full_spn = (spn_coarse & 0xFFFE0000) + spn_fine
```
The coarse SPN provides the upper 15 bits (masked to clear the lower 17). The fine SPN provides the lower 17 bits. The result is a Source Packet Number -- each source packet is 192 bytes in the M2TS file.
### Resolved EP Map
The `resolved_ep_map()` method iterates through all coarse entries and their associated fine entries to produce a flat list of `(PTS, SPN)` pairs. For each coarse entry at index `ci`:
- Fine entries range from `coarse[ci].ref_to_fine_id` to `coarse[ci+1].ref_to_fine_id` (or end of fine table for the last coarse entry).
- Each fine entry is combined with its parent coarse entry to produce one full `(PTS, SPN)` pair.
## Deriving Sector Extents for Ripping
Given a playlist's in_time and out_time (from MPLS), the CLPI EP map provides the sector ranges to read from disc. The `get_extents()` method does this in three steps:
### Step 1: PTS to SPN
Binary search the resolved EP map for the in_time and out_time:
- **start_spn**: the SPN at or before in_time (seek backward to the nearest I-frame)
- **end_spn**: the SPN at or after out_time (include the full GOP)
### Step 2: SPN to Byte Offset
Each Source Packet is 192 bytes (188 bytes MPEG-TS payload + 4 bytes M2TS header):
```
byte_offset = spn * 192
```
### Step 3: Byte Offset to Sector
M2TS files are stored contiguously on disc. Sectors are 2048 bytes:
```
start_sector = start_byte / 2048
end_sector = (end_byte + 2047) / 2048
sector_count = end_sector - start_sector
```
The resulting `Extent` contains `start_lba` (relative to the M2TS file's starting sector on disc) and `sector_count`. The caller adds the file's absolute starting LBA from UDF to get disc-absolute sector numbers.
### Alignment Note
The 192-byte source packet size and 2048-byte sector size share no common factor beyond 1. A single sector contains roughly 10.67 source packets. The conversion rounds start down and end up to ensure complete coverage.
## Putting It Together
The full ripping pipeline chains three parsers:
1. **MPLS** provides clip IDs and in/out timestamps.
2. **CLPI** converts those timestamps to SPN ranges, then to sector extents.
3. **UDF** provides the file's starting LBA on disc for absolute sector addressing.
The `Disc::scan()` method in `src/disc.rs` orchestrates this: for each play item in each playlist, it loads the corresponding CLPI, calls `get_extents()` with the play item's in/out times, and collects the resulting sector ranges into the title's extent list.
## References
- BD-ROM Part 3, Section 5.5: Clip Information file format
- https://github.com/lw/BluRay/wiki/CLPI
+114
View File
@@ -0,0 +1,114 @@
# Disc to Rip: End-to-End Flow
How libfreemkv goes from a disc in the drive to decrypted content ready for backup.
This is the starting point for understanding the library.
## The Pipeline
```
Insert disc
│
▼
1. Open drive (drive.rs)
│ INQUIRY → identify drive
│ Match bundled profile → chipset, unlock parameters
│
▼
2. AACS handshake (aacs_handshake.rs) — optional, separate transport
│ Allocate AGID
│ Exchange certificates + nonces (ECDH)
│ Derive bus key
│ Read Volume ID + read_data_key
│ (fails gracefully if drive doesn't support AACS for this disc)
│
▼
3. Unlock drive (drive.rs → platform/mt1959.rs)
│ Vendor-specific command activates raw read mode
│ Required — drive firmware blocks all reads without it
│
▼
4. Read UDF filesystem (udf.rs)
│ Sector 256: AVDP → find Volume Descriptor Sequence
│ VDS: Partition Descriptor (physical start) + Logical Volume (metadata start)
│ Metadata partition → File Set Descriptor → Root directory
│ Walk directory tree: BDMV/, AACS/, CERTIFICATE/
│ → docs/udf.md
│
▼
5. Read AACS files from disc (aacs.rs)
│ AACS/Unit_Key_RO.inf → SHA1 = disc hash
│ AACS/Content000.cer → AACS version (1.0 or 2.0), bus encryption flag
│ MKB via SCSI → for key derivation fallback
│
▼
6. Resolve AACS keys (aacs.rs → resolve_keys)
│ Path 1: disc hash → KEYDB.cfg → VUK (fast, 99% of discs)
│ Path 2: KEYDB media key + Volume ID → VUK
│ Path 3: MKB + processing keys → media key → VUK
│ Path 4: MKB + device keys → subset-difference tree → VUK
│ VUK → decrypt unit keys from Unit_Key_RO.inf
│ → docs/aacs.md
│
▼
7. Parse playlists (mpls.rs)
│ BDMV/PLAYLIST/*.mpls → titles with play items
│ Each play item: clip ID, in/out timestamps
│ STN table: video, audio, subtitle streams with codec + language
│ → docs/mpls.md
│
▼
8. Parse clip info (clpi.rs)
│ BDMV/CLIPINF/*.clpi → EP map (timestamp → sector mapping)
│ Coarse + fine entries → full PTS and SPN
│ SPN → byte offset → sector extents for reading
│ → docs/clpi.md
│
▼
9. Parse BD-J labels (jar.rs) — optional
│ BDMV/JAR/*.jar → Java class constant pool strings
│ Audio track labels: "English Descriptive Audio", "French 5.1", etc.
│
▼
10. Read + decrypt content (disc.rs → ContentReader)
│ For each aligned unit (6144 bytes = 3 sectors):
│ Read 3 sectors from disc
│ If AACS 2.0: bus decrypt (read_data_key, per-sector AES-CBC)
│ If encrypted: unit decrypt (per-unit key derivation + AES-CBC)
│ Output decrypted content
│
▼
Decrypted m2ts stream → ready for muxing/backup
```
## API Summary
```rust
// Steps 1 + 3 (open + unlock)
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// Steps 2 + 4-9 (AACS + scan)
let disc = Disc::scan(&mut session, &ScanOptions::with_keydb("keydb.cfg"))?;
// Step 10 (read + decrypt)
let mut reader = disc.open_title(&mut session, 0)?;
while let Some(unit) = reader.read_unit()? {
output.write_all(&unit)?;
}
```
Three lines. Everything else is internal.
## Module Reference
| Module | Doc | Purpose |
|--------|-----|---------|
| drive.rs | [drive-access.md](drive-access.md) | Open, identify, unlock, read |
| scsi.rs | [drive-access.md](drive-access.md) | Platform SCSI transport |
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
| clpi.rs | [clpi.md](clpi.md) | CLPI clip info + EP map |
| aacs.rs | [aacs.md](aacs.md) | Key resolution + content decrypt |
| aacs_handshake.rs | [aacs.md](aacs.md) | SCSI bus authentication |
| disc.rs | -- | High-level scan + read API |
| jar.rs | -- | BD-J audio track labels |
| error.rs | -- | Error codes (E1xxx-E7xxx) |
+253
View File
@@ -0,0 +1,253 @@
# Drive Access and Unlock
Technical reference for how libfreemkv opens, identifies, unlocks, and reads
optical drives.
---
## DriveSession
`DriveSession` is the primary API. It owns the SCSI transport, the matched
drive profile, and the chipset-specific platform driver.
### Opening a Drive
```rust
// Full open: identify → match profile → unlock
let mut session = DriveSession::open(Path::new("/dev/sr0"))?;
// No-unlock open: identify → match profile only
let mut session = DriveSession::open_no_unlock(Path::new("/dev/sr0"))?;
// Explicit profile (skip auto-detection)
let mut session = DriveSession::open_with_profile(Path::new("/dev/sr0"), profile)?;
```
**`open()`** performs the full sequence: open device, send INQUIRY, match
profile, instantiate platform driver, and unlock. Unlock failures are silently
ignored (unencrypted discs do not need it). After `open()`, both raw sector
reads and standard READ(10) work immediately.
**`open_no_unlock()`** skips the unlock step. This is required when AACS bus
authentication must happen before unlock. The handshake uses standard SCSI
commands that work without raw mode. After authentication completes, the caller
can invoke `session.unlock()` manually.
**`open_with_profile()`** bypasses profile auto-detection. Useful for testing
or when a custom profile is loaded from an external source.
### Session Operations
| Method | Description |
|--------|-------------|
| `unlock()` | Activate raw disc access mode via platform driver |
| `is_unlocked()` | Check if raw mode is active |
| `calibrate()` | Build speed lookup table for the current disc |
| `read_sectors(lba, count, buf)` | Raw sector read (requires unlock + calibrate) |
| `read_disc(lba, count, buf)` | Standard READ(10) with 5s timeout |
| `status()` | Query drive status and feature flags |
| `read_config()` | Read drive configuration block (1888 bytes) |
| `read_register(index)` | Read 16-byte hardware register |
| `probe(sub_cmd, addr, len)` | Generic READ BUFFER with caller parameters |
| `scsi_execute(cdb, dir, buf, timeout)` | Send an arbitrary SCSI CDB |
---
## SCSI Transport
### Trait
```rust
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
```
All drive communication goes through this trait. The library never opens file
descriptors or calls ioctls outside of a `ScsiTransport` implementation.
### Linux: SG_IO
The `SgIoTransport` implementation:
1. Opens the device path with `O_RDWR | O_NONBLOCK`.
2. Constructs an `sg_io_hdr` struct with the CDB, data buffer, and timeout.
3. Calls `ioctl(fd, SG_IO, &hdr)`.
4. Returns `ScsiResult` with status, bytes transferred, and sense data.
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
sense buffer and returns `Error::ScsiError`.
### CDB Builders
The `scsi` module provides platform-agnostic CDB constructors:
| Function | CDB | Use |
|----------|-----|-----|
| `inquiry()` | INQUIRY (0x12) | Drive identification |
| `get_config_010c()` | GET CONFIGURATION (0x46) | Feature 010C firmware date |
| `build_read_buffer()` | READ BUFFER (0x3C) | All platform commands |
| `build_set_cd_speed()` | SET CD SPEED (0xBB) | Speed control |
| `build_read10_raw()` | READ(10) (0x28) with flag 0x08 | Raw sector reads |
---
## Drive Identification
`DriveId::from_drive()` sends two standard SCSI commands and extracts identity
fields:
| Field | Source | SCSI Reference |
|-------|--------|----------------|
| `vendor_id` | INQUIRY bytes [8:16] | SPC-4 section 6.4.2 |
| `product_id` | INQUIRY bytes [16:32] | SPC-4 section 6.4.2 |
| `product_revision` | INQUIRY bytes [32:36] | SPC-4 section 6.4.2 |
| `vendor_specific` | INQUIRY bytes [36:43] | SPC-4 section 6.4.2 |
| `firmware_date` | GET CONFIGURATION Feature 010C | MMC-6 section 5.3.10 |
The match key is `"VENDOR|PRODUCT|REVISION|VENDOR_SPECIFIC"`. Profile matching
tries all four fields first, then falls back to matching without the firmware
date for drives where Feature 010C is unavailable.
---
## Drive Profiles
Profiles are JSON objects compiled into the binary (`profiles.json`,
206 entries). Each profile contains:
| Field | Purpose |
|-------|---------|
| `vendor_id`, `product_revision`, `vendor_specific`, `firmware_date` | Matching fields |
| `chipset` | `"mediatek"` or `"renesas"` |
| `unlock_mode`, `unlock_buf_id` | READ BUFFER CDB parameters |
| `signature` | Expected 4-byte response signature |
| `unlock_cdb` | Pre-built unlock CDB (hex-encoded) |
| `register_offsets` | Offsets for hardware register reads |
| `capabilities` | Feature flags: `bd_raw_read`, `dvd_all_regions`, etc. |
Loading:
```rust
// Bundled (compiled-in) -- no file I/O
let profiles = profile::load_bundled()?;
// External file
let profiles = profile::load_all(Path::new("/path/to/profiles.json"))?;
```
---
## Chipsets
### MediaTek MT1959
Covers all LG, ASUS, and hp optical drives. Two sub-variants share identical
logic with different SCSI parameters:
| Variant | READ BUFFER mode | Buffer ID |
|---------|------------------|-----------|
| MT1959-A | 0x01 | 0x44 |
| MT1959-B | 0x02 | 0x77 |
The Platform trait maps to 10 command handlers:
| Handler | Function | Description |
|---------|----------|-------------|
| 0 | `unlock()` | Send READ BUFFER, verify signature + verification bytes |
| 1 | `read_config()` | Read 1888-byte configuration block + 4-byte status |
| 2-3 | `read_register()` | Read hardware registers at profile-specified offsets |
| 4 | `calibrate()` | Probe disc surface, build 64-entry speed table |
| 5 | `keepalive()` | Periodic session maintenance |
| 6 | `status()` | Query current mode and feature flags |
| 7 | `probe()` | Generic READ BUFFER with dynamic parameters |
| 8 | `read_sectors()` | Speed lookup + SET CD SPEED + READ(10) with flag 0x08 |
| 9 | `timing()` | Timing calibration |
### Renesas (Planned)
RS8xxx/RS9xxx chipsets used in Pioneer and some HL-DT-ST drives.
Currently returns `Error::UnsupportedDrive` when a Renesas profile is matched.
---
## Why Unlock Is Needed
Optical drive firmware restricts what applications can read from disc. Without
unlock:
- **READ(10) works for unencrypted filesystem data.** UDF structures, MPLS
playlists, and CLPI clip info are readable without unlock. The `read_disc()`
method uses standard READ(10) and works on any drive.
- **READ(10) fails for encrypted content sectors.** The drive firmware returns
SCSI errors (sense key 0x05, illegal request) when an application attempts to
read sectors containing encrypted m2ts content without prior AACS
authentication via the bus key.
- **The kernel sr driver blocks block-device reads.** On Linux, the kernel's
SCSI CD-ROM driver (`sr`) refuses to expose encrypted disc content through
`/dev/sr0` as a block device. Even if you open the block device directly,
reads to encrypted regions fail.
- **Raw mode bypasses firmware restrictions.** After unlock, the drive accepts
READ(10) with the raw read flag (CDB byte 1 = 0x08) for all sectors,
regardless of encryption status. This is how raw sector ripping works.
### open() vs open_no_unlock()
AACS bus authentication uses standard MMC REPORT KEY / SEND KEY commands.
These must execute before unlock because:
1. The AACS handshake establishes a bus key via ECDH.
2. The bus key encrypts the Volume ID and Read Data Key responses.
3. The Volume ID is needed to derive the Volume Unique Key (VUK).
4. The VUK is needed to decrypt unit keys from `Unit_Key_RO.inf`.
If `open()` unlocks first, some drives reject the subsequent AACS commands.
The correct sequence for encrypted discs is:
```rust
// 1. Open without unlock
let mut session = DriveSession::open_no_unlock(device)?;
// 2. AACS handshake (uses standard SCSI, no unlock needed)
let auth = aacs_handshake::aacs_authenticate(&mut session, &key, &cert)?;
let vid = aacs_handshake::read_volume_id(&mut session, &mut auth)?;
// 3. Now unlock for raw reads
session.unlock()?;
session.calibrate()?;
// 4. Read and decrypt content
session.read_sectors(lba, count, &mut buf)?;
```
In practice, `Disc::scan()` handles this internally. The default `open()` call
unlocks immediately and is correct for most use cases -- the scan re-opens a
second session with `open_no_unlock()` for the AACS handshake when needed.
---
## Speed Control
After `calibrate()`, the platform driver maintains a 64-entry speed lookup table
built by probing the disc surface. On each `read_sectors()` call, the driver:
1. Looks up the optimal speed for the target LBA in the table.
2. Issues SET CD SPEED (0xBB) if the speed differs from current.
3. Performs the READ(10).
Available speeds:
| Format | Speeds |
|--------|--------|
| Blu-ray | 1x (4,500 KB/s) through 12x (54,000 KB/s) |
| DVD | 1x (1,385 KB/s) through 16x (22,160 KB/s) |
| Max | 0xFFFF (drive decides) |
+217
View File
@@ -0,0 +1,217 @@
# MPLS Playlist Format
## What is MPLS?
MPLS (Movie PlayList) files define playback titles on a Blu-ray disc. Each `.mpls` file in `BDMV/PLAYLIST/` describes one title -- a sequence of clips with precise in/out timestamps and stream information. The main movie, bonus features, trailers, and menus each have their own MPLS file.
A disc may contain dozens of MPLS files. Most are short (menus, logos, transitions). The main movie is typically the longest playlist. libfreemkv filters out playlists shorter than 30 seconds.
## File Structure
MPLS files use big-endian byte order throughout. The file has three main sections:
```
Offset Size Field
------ ---- -----
0 4 Magic: "MPLS"
4 4 Version: "0200" (BD) or "0300" (UHD BD)
8 4 PlayList start offset (absolute from file start)
12 4 PlayListMark start offset
16 4 ExtensionData start offset
```
### PlayList Section
Located at the PlayList start offset. Contains all play items and sub-path entries:
```
Offset Size Field
------ ---- -----
0 4 PlayList length
4 2 Reserved
6 2 Number of PlayItems
8 2 Number of SubPaths
10 ... PlayItem entries (variable length)
```
### Play Items
Each play item references one clip and specifies what portion to play:
```
Offset Size Field
------ ---- -----
0 2 PlayItem length (bytes after this field)
2 5 Clip ID (ASCII, e.g. "00001")
7 4 Codec ID ("M2TS")
11 1 Connection condition (lower 4 bits)
12 1 Ref to STC_id
14 4 IN_time (45kHz PTS ticks)
18 4 OUT_time (45kHz PTS ticks)
22 8 UO_mask_table
30 1 Misc flags
31 1 still_mode
32 ... STN_table (first play item only is parsed)
```
**Connection condition** values:
- `1` = seamless connection (no gap between clips)
- `5`, `6` = non-seamless connection
**Timestamps** use 45kHz PTS (Presentation Time Stamp) ticks, the same timebase as MPEG transport streams. Duration of a play item = `OUT_time - IN_time`. To convert to seconds: divide by 45000.
A playlist's total duration is the sum of all play item durations.
### Clip ID Mapping
The Clip ID (e.g. "00001") maps to:
- `BDMV/STREAM/00001.m2ts` -- the transport stream
- `BDMV/CLIPINF/00001.clpi` -- the clip info (EP map, stream details)
## STN Table
The Stream Number Table describes all elementary streams available in the clip. It is parsed from the first play item (which defines the title's stream layout).
```
Offset Size Field
------ ---- -----
0 2 STN table length
2 2 Reserved
4 1 Number of primary video streams
5 1 Number of primary audio streams
6 1 Number of PG (subtitle) streams
7 1 Number of IG (interactive graphics) streams
8 ... Stream entries
```
### Stream Entries
Each stream entry has two parts: a stream reference and a stream attributes block.
**Stream reference:**
```
Offset Size Field
------ ---- -----
0 1 Entry length
1 1 Stream type (1=PlayItem, 2=SubPath, 3=InMux)
2 2 PID (MPEG-TS packet ID, big-endian)
```
**Stream attributes** (immediately follows the reference):
```
Offset Size Field
------ ---- -----
0 1 Attributes length
1 1 Coding type
2+ ... Type-specific fields
```
The layout of type-specific fields depends on the stream category:
**Video streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 1 | Format (upper 4 bits) + frame rate (lower 4 bits) |
**Audio streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 1 | Format (upper 4 bits) + sample rate (lower 4 bits) |
| 3 | 3 | Language code (ISO 639-2, e.g. "eng") |
**PG subtitle and IG streams:**
| Offset | Size | Field |
|--------|------|-------|
| 1 | 1 | Coding type |
| 2 | 3 | Language code |
## Coding Types
The coding type byte identifies the codec:
| Value | Codec | Category |
|-------|-------|----------|
| `0x02` | MPEG-2 | Video |
| `0x1B` | H.264 / AVC | Video |
| `0x24` | HEVC / H.265 | Video |
| `0xEA` | VC-1 | Video |
| `0x80` | LPCM | Audio |
| `0x81` | AC-3 (Dolby Digital) | Audio |
| `0x82` | DTS | Audio |
| `0x83` | TrueHD (Dolby TrueHD) | Audio |
| `0x84` | AC-3 Plus (E-AC-3) | Audio |
| `0x85` | DTS-HD HR | Audio |
| `0x86` | DTS-HD MA | Audio |
| `0xA1` | AC-3 Plus (secondary) | Audio |
| `0xA2` | DTS-HD HR (secondary) | Audio |
| `0x90` | PGS (Presentation Graphics) | Subtitle |
| `0x91` | PGS (Interactive Graphics) | Subtitle |
## Video Format Codes
| Value | Resolution |
|-------|-----------|
| 1 | 480i |
| 2 | 576i |
| 3 | 480p |
| 4 | 1080i |
| 5 | 720p |
| 6 | 1080p |
| 7 | 576p |
| 8 | 2160p |
## Video Frame Rate Codes
| Value | Frame Rate |
|-------|-----------|
| 1 | 23.976 fps |
| 2 | 24 fps |
| 3 | 25 fps |
| 4 | 29.97 fps |
| 6 | 50 fps |
| 7 | 59.94 fps |
## Audio Format Codes
| Value | Channels |
|-------|----------|
| 1 | Mono |
| 3 | Stereo |
| 6 | 5.1 surround |
| 12 | 7.1 surround |
## Audio Sample Rate Codes
| Value | Rate |
|-------|------|
| 1 | 48 kHz |
| 4 | 96 kHz |
| 5 | 192 kHz |
| 12 | 48/192 kHz (combo) |
| 14 | 48/96 kHz (combo) |
## How Playlists Map to Titles
libfreemkv's `Disc::scan()` reads every MPLS file from the disc and builds a `Title` for each:
1. Read all `.mpls` files from `BDMV/PLAYLIST/` via UDF.
2. Parse each with `mpls::parse()`.
3. Calculate duration by summing `(OUT_time - IN_time)` across all play items.
4. Discard playlists shorter than 30 seconds.
5. For each play item, load the corresponding CLPI file to get EP map data and compute sector extents (see [clpi.md](clpi.md)).
6. Extract stream info from the STN table of the first play item.
7. Sort titles by duration, longest first.
The resulting `Title` struct contains everything needed to rip: streams, duration, byte size, and the sector extents to read from disc.
## References
- BD-ROM Part 3, Section 5.3: PlayList file format
- https://github.com/lw/BluRay/wiki/MPLS
+169
View File
@@ -0,0 +1,169 @@
# UDF 2.50 Filesystem Parser
## What is UDF?
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the **metadata partition** -- a critical feature that separates file metadata from file content on disc.
## Why UDF 2.50 for Blu-ray?
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
## Metadata Partitions
A UDF 2.50 BD-ROM has two logical partitions:
- **Partition 0 (Type 1)** -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's `partitionStartingLocation`.
- **Partition 1 (Type 2)** -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
The key rule: **ICBs and directory data live in the metadata partition. File content lives in the physical partition.** When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
## Pointer Chain
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
```
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
v
Sectors 32-63: VDS (Volume Descriptor Sequence)
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
v
Partition Maps in LVD (offset 440):
|-- Map 0: Type 1 (physical partition)
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
v
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|-- Allocation descriptor --> metadata content location
|
v
metadata_start = partition_start + allocation_position
|
v
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
v
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|-- Allocation descriptor --> directory data location (metadata-relative)
|
v
Directory data: File Identifier Descriptors (tag 257)
|-- Each FID names a file/subdirectory and points to its ICB
|-- Recurse into subdirectories to build the full file tree
```
### AVDP (Sector 256)
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
### VDS (Sectors 32+)
The Volume Descriptor Sequence contains:
- **Partition Descriptor (tag 5)**: byte offset 188 holds `partitionStartingLocation` -- the absolute sector where the physical partition begins.
- **Logical Volume Descriptor (tag 6)**: byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
- **Terminating Descriptor (tag 8)**: signals the end of the VDS.
### Metadata File
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
### File Set Descriptor
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
### Directory Traversal
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
| FID Field | Offset | Size | Description |
|-----------|--------|------|-------------|
| Tag | 0 | 2 | Always 257 |
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
| L_FI (name length) | 19 | 1 | Length of filename |
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
| L_IU | 36 | 2 | Implementation use length |
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
FIDs are 4-byte aligned. The parser advances by `(38 + L_IU + L_FI + 3) & !3` bytes per entry.
### ICB Layout
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
| Field | Tag 261 Offset | Tag 266 Offset |
|-------|---------------|---------------|
| info_length (u64) | 56 | 56 |
| L_EA (u32) | 168 | 208 |
| L_AD (u32) | 172 | 212 |
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
## How read_filesystem() Works
The `read_filesystem()` function in `src/udf.rs` follows the pointer chain above:
1. Reads sector 256, validates AVDP (tag 2).
2. Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
3. If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
4. Reads the FSD at metadata_start, extracts the root directory ICB LBA.
5. Calls `read_directory()` recursively (max depth 3) to build the full file tree.
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
### UDF Filename Encoding
UDF filenames use a compression ID as the first byte:
- `8` = 8-bit characters (ASCII)
- `16` = 16-bit big-endian Unicode (UTF-16BE)
The parser handles both encodings. All path lookups are case-insensitive.
## BD-ROM Directory Structure
A typical Blu-ray disc has this directory layout:
```
/
+-- BDMV/
| +-- index.bdmv Disc index (title list, first play)
| +-- MovieObject.bdmv Movie objects (navigation commands)
| +-- PLAYLIST/
| | +-- 00000.mpls Main movie playlist
| | +-- 00001.mpls Director's commentary
| | +-- ...
| +-- CLIPINF/
| | +-- 00001.clpi Clip info for 00001.m2ts
| | +-- 00002.clpi
| | +-- ...
| +-- STREAM/
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
| | +-- 00002.m2ts
| | +-- ...
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
+-- AACS/ AACS encryption data (encrypted discs only)
| +-- Unit_Key_RO.inf Unit key file (encrypted)
| +-- MKB_RW.inf Media Key Block
| +-- Content000.cer Content certificate
| +-- DUPLICATE/ Backup copies
|
+-- CERTIFICATE/ BD+ certificate data (some discs)
```
The parser reads from `BDMV/PLAYLIST/` and `BDMV/CLIPINF/` to discover titles and their sector layouts. The `BDMV/STREAM/` directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
## References
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
- UDF 2.50 (OSTA): Universal Disk Format Specification
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications
+789
View File
@@ -0,0 +1,789 @@
//! AACS bus authentication handshake — ECDH key agreement + bus key derivation.
//!
//! Implements the AACS SCSI authentication protocol to obtain:
//! - Volume ID (VID) — needed for VUK derivation
//! - Read Data Key — needed for AACS 2.0 (UHD) bus decryption
//!
//! Flow:
//! 1. Invalidate AGIDs → allocate fresh AGID
//! 2. Send host certificate + nonce
//! 3. Receive drive certificate + nonce
//! 4. Receive drive key point + signature, verify
//! 5. Sign host key point, send
//! 6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
//! 7. Read VID or Read Data Keys (encrypted with bus key)
//!
//! Supports:
//! - AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
//! - AACS 2.0: drives accept AACS 1.0 host certs for backward compatibility
//! (full P-256/SHA-256 AACS 2.0 handshake prepared but rarely needed)
use crate::error::{Error, Result};
use crate::drive::DriveSession;
use crate::scsi::DataDirection;
use num_bigint::BigUint;
use num_traits::{One, Zero};
use sha1::{Sha1, Digest};
/// Execute a SCSI command that reads data from the device.
fn scsi_read(session: &mut DriveSession, cdb: &[u8], len: usize) -> Result<Vec<u8>> {
let mut buf = vec![0u8; len];
session.scsi_execute(cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf)
}
/// Execute a SCSI command that writes data to the device.
fn scsi_write(session: &mut DriveSession, cdb: &[u8], data: &[u8]) -> Result<()> {
let mut buf = data.to_vec();
session.scsi_execute(cdb, DataDirection::ToDevice, &mut buf, 5_000)?;
Ok(())
}
// ── AACS 1.0 elliptic curve parameters (160-bit) ───────────────────────────
const EC_P: [u8; 20] = [
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, 0x79, 0xA7, 0xD7, 0xDF,
];
const EC_A: [u8; 20] = [
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
0xB0, 0x9E, 0xF9, 0xEA, 0xE7, 0xC4, 0x79, 0xA7, 0xD7, 0xDC,
];
#[cfg(test)]
const EC_B: [u8; 20] = [
0x40, 0x2D, 0xAD, 0x3E, 0xC1, 0xCB, 0xCD, 0x16, 0x52, 0x48,
0xD6, 0x8E, 0x12, 0x45, 0xE0, 0xC4, 0xDA, 0xAC, 0xB1, 0xD8,
];
const EC_N: [u8; 20] = [
0x9D, 0xC9, 0xD8, 0x13, 0x55, 0xEC, 0xCE, 0xB5, 0x60, 0xBD,
0xC4, 0x4F, 0x54, 0x81, 0x7B, 0x2C, 0x7F, 0x5A, 0xB0, 0x17,
];
const EC_GX: [u8; 20] = [
0x2E, 0x64, 0xFC, 0x22, 0x57, 0x83, 0x51, 0xE6, 0xF4, 0xCC,
0xA7, 0xEB, 0x81, 0xD0, 0xA4, 0xBD, 0xC5, 0x4C, 0xCE, 0xC6,
];
const EC_GY: [u8; 20] = [
0x09, 0x14, 0xA2, 0x5D, 0xD0, 0x54, 0x42, 0x88, 0x9D, 0xB4,
0x55, 0xC7, 0xF2, 0x3C, 0x9A, 0x07, 0x07, 0xF5, 0xCB, 0xB9,
];
// ── AACS LA (Licensing Administrator) public key for cert verification ──────
const AACS_LA_PUB_X: [u8; 20] = [
0x01, 0xF3, 0x5D, 0xAB, 0xD8, 0xAE, 0x5F, 0x40, 0x56, 0x5E,
0x30, 0xC8, 0x8A, 0x60, 0x42, 0x82, 0x07, 0x61, 0xDF, 0x93,
];
const AACS_LA_PUB_Y: [u8; 20] = [
0x44, 0x87, 0xB5, 0xAC, 0x07, 0x10, 0x8D, 0x10, 0x5B, 0xA5,
0xB9, 0xE3, 0x2F, 0x3B, 0xBB, 0xFC, 0x0C, 0x2C, 0xBC, 0xD1,
];
// ── Elliptic curve arithmetic over GF(p) ───────────────────────────────────
#[derive(Clone, Debug)]
struct EcPoint {
x: BigUint,
y: BigUint,
infinity: bool,
}
impl EcPoint {
fn infinity() -> Self {
EcPoint { x: BigUint::zero(), y: BigUint::zero(), infinity: true }
}
fn new(x: BigUint, y: BigUint) -> Self {
EcPoint { x, y, infinity: false }
}
fn from_bytes(x_bytes: &[u8], y_bytes: &[u8]) -> Self {
EcPoint::new(BigUint::from_bytes_be(x_bytes), BigUint::from_bytes_be(y_bytes))
}
}
/// Modular inverse using extended Euclidean algorithm.
fn mod_inv(a: &BigUint, m: &BigUint) -> Option<BigUint> {
use num_bigint::BigInt;
use num_traits::Signed;
let a = BigInt::from(a.clone());
let m = BigInt::from(m.clone());
let (mut old_r, mut r) = (a, m.clone());
let (mut old_s, mut s) = (BigInt::one(), BigInt::zero());
while !r.is_zero() {
let q = &old_r / &r;
let temp_r = r.clone();
r = old_r - &q * &r;
old_r = temp_r;
let temp_s = s.clone();
s = old_s - &q * &s;
old_s = temp_s;
}
if old_r != BigInt::one() {
return None;
}
if old_s.is_negative() {
old_s += &m;
}
Some(old_s.to_biguint().unwrap())
}
/// EC point addition on curve y² = x³ + ax + b (mod p).
fn ec_add(p1: &EcPoint, p2: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
if p1.infinity { return p2.clone(); }
if p2.infinity { return p1.clone(); }
if p1.x == p2.x {
if p1.y == p2.y && !p1.y.is_zero() {
return ec_double(p1, a, p);
}
return EcPoint::infinity();
}
// λ = (y2 - y1) / (x2 - x1) mod p
let dy = if p2.y >= p1.y {
(&p2.y - &p1.y) % p
} else {
(p - (&p1.y - &p2.y) % p) % p
};
let dx = if p2.x >= p1.x {
(&p2.x - &p1.x) % p
} else {
(p - (&p1.x - &p2.x) % p) % p
};
let dx_inv = mod_inv(&dx, p).unwrap();
let lam = (&dy * &dx_inv) % p;
// x3 = λ² - x1 - x2 mod p
let x3 = {
let lam2 = (&lam * &lam) % p;
let sum = (&p1.x + &p2.x) % p;
if lam2 >= sum {
(lam2 - sum) % p
} else {
(p - (sum - lam2) % p) % p
}
};
// y3 = λ(x1 - x3) - y1 mod p
let y3 = {
let diff = if p1.x >= x3 {
(&p1.x - &x3) % p
} else {
(p - (&x3 - &p1.x) % p) % p
};
let prod = (&lam * &diff) % p;
if prod >= p1.y {
(prod - &p1.y) % p
} else {
(p - (&p1.y - prod) % p) % p
}
};
EcPoint::new(x3, y3)
}
/// EC point doubling.
fn ec_double(pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
if pt.infinity || pt.y.is_zero() {
return EcPoint::infinity();
}
// λ = (3x² + a) / (2y) mod p
let three = BigUint::from(3u32);
let two = BigUint::from(2u32);
let numerator = (&three * &pt.x * &pt.x + a) % p;
let denominator = (&two * &pt.y) % p;
let denom_inv = mod_inv(&denominator, p).unwrap();
let lam = (&numerator * &denom_inv) % p;
// x3 = λ² - 2x mod p
let x3 = {
let lam2 = (&lam * &lam) % p;
let two_x = (&two * &pt.x) % p;
if lam2 >= two_x {
(lam2 - two_x) % p
} else {
(p - (two_x - lam2) % p) % p
}
};
// y3 = λ(x - x3) - y mod p
let y3 = {
let diff = if pt.x >= x3 {
(&pt.x - &x3) % p
} else {
(p - (&x3 - &pt.x) % p) % p
};
let prod = (&lam * &diff) % p;
if prod >= pt.y {
(prod - &pt.y) % p
} else {
(p - (&pt.y - prod) % p) % p
}
};
EcPoint::new(x3, y3)
}
/// Scalar multiplication using double-and-add.
fn ec_mul(k: &BigUint, pt: &EcPoint, a: &BigUint, p: &BigUint) -> EcPoint {
if k.is_zero() {
return EcPoint::infinity();
}
let mut result = EcPoint::infinity();
let mut base = pt.clone();
let mut scalar = k.clone();
while !scalar.is_zero() {
if scalar.bit(0) {
result = ec_add(&result, &base, a, p);
}
base = ec_double(&base, a, p);
scalar >>= 1;
}
result
}
/// Convert BigUint to fixed-size big-endian bytes, zero-padded.
fn to_bytes_be_padded(n: &BigUint, len: usize) -> Vec<u8> {
let bytes = n.to_bytes_be();
if bytes.len() >= len {
bytes[bytes.len() - len..].to_vec()
} else {
let mut padded = vec![0u8; len - bytes.len()];
padded.extend_from_slice(&bytes);
padded
}
}
// ── ECDSA ───────────────────────────────────────────────────────────────────
/// ECDSA sign: sign SHA-1(data) with private key on AACS curve.
/// Returns (r, s) each 20 bytes.
fn ecdsa_sign(priv_key: &[u8; 20], data: &[u8]) -> ([u8; 20], [u8; 20]) {
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let n = BigUint::from_bytes_be(&EC_N);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let d = BigUint::from_bytes_be(priv_key);
// Hash the data
let hash = Sha1::digest(data);
let z = BigUint::from_bytes_be(&hash);
loop {
// Generate random k
let mut k_bytes = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut k_bytes);
let k = BigUint::from_bytes_be(&k_bytes) % &n;
if k.is_zero() { continue; }
// R = k × G
let r_point = ec_mul(&k, &g, &a, &p);
let r = &r_point.x % &n;
if r.is_zero() { continue; }
// s = k⁻¹(z + r·d) mod n
let k_inv = match mod_inv(&k, &n) {
Some(v) => v,
None => continue,
};
let s = (&k_inv * ((&z + &r * &d) % &n)) % &n;
if s.is_zero() { continue; }
let r_bytes = to_bytes_be_padded(&r, 20);
let s_bytes = to_bytes_be_padded(&s, 20);
let mut r_out = [0u8; 20];
let mut s_out = [0u8; 20];
r_out.copy_from_slice(&r_bytes);
s_out.copy_from_slice(&s_bytes);
return (r_out, s_out);
}
}
/// ECDSA verify: verify signature (r, s) against SHA-1(data) using public key.
fn ecdsa_verify(pub_x: &[u8; 20], pub_y: &[u8; 20], sig_r: &[u8; 20], sig_s: &[u8; 20], data: &[u8]) -> bool {
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let n = BigUint::from_bytes_be(&EC_N);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let q = EcPoint::from_bytes(pub_x, pub_y);
let r = BigUint::from_bytes_be(sig_r);
let s = BigUint::from_bytes_be(sig_s);
if r.is_zero() || r >= n || s.is_zero() || s >= n {
return false;
}
let hash = Sha1::digest(data);
let z = BigUint::from_bytes_be(&hash);
let s_inv = match mod_inv(&s, &n) {
Some(v) => v,
None => return false,
};
let u1 = (&z * &s_inv) % &n;
let u2 = (&r * &s_inv) % &n;
let p1 = ec_mul(&u1, &g, &a, &p);
let p2 = ec_mul(&u2, &q, &a, &p);
let r_point = ec_add(&p1, &p2, &a, &p);
if r_point.infinity {
return false;
}
&r_point.x % &n == r
}
// ── AACS certificate handling ───────────────────────────────────────────────
/// Verify an AACS certificate (92 bytes) against the AACS LA public key.
fn verify_cert(cert: &[u8]) -> bool {
if cert.len() < 92 { return false; }
// Certificate format: type(1) + flags(1) + padding(2) + serial(6) + pub_x(20) + pub_y(20) + sig_r(20) + sig_s(20)
// Signature is over the first 52 bytes
let mut sig_r = [0u8; 20];
let mut sig_s = [0u8; 20];
sig_r.copy_from_slice(&cert[52..72]);
sig_s.copy_from_slice(&cert[72..92]);
ecdsa_verify(&AACS_LA_PUB_X, &AACS_LA_PUB_Y, &sig_r, &sig_s, &cert[..52])
}
/// Extract public key from certificate.
fn cert_pub_key(cert: &[u8]) -> ([u8; 20], [u8; 20]) {
let mut x = [0u8; 20];
let mut y = [0u8; 20];
x.copy_from_slice(&cert[12..32]);
y.copy_from_slice(&cert[32..52]);
(x, y)
}
// ── Bus key derivation (ECDH) ───────────────────────────────────────────────
/// Compute bus key via ECDH: bus_key = low 128 bits of (host_priv × drive_key_point).x
fn compute_bus_key(host_priv: &[u8; 20], drive_key_point_x: &[u8; 20], drive_key_point_y: &[u8; 20]) -> [u8; 16] {
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let d = BigUint::from_bytes_be(host_priv);
let dkp = EcPoint::from_bytes(drive_key_point_x, drive_key_point_y);
let shared = ec_mul(&d, &dkp, &a, &p);
// Bus key = lowest 128 bits (last 16 bytes) of x-coordinate
let x_bytes = to_bytes_be_padded(&shared.x, 20);
let mut bus_key = [0u8; 16];
bus_key.copy_from_slice(&x_bytes[4..20]); // last 16 of 20
bus_key
}
/// Generate ephemeral host key pair: (private_key, public_point_x, public_point_y).
fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
let p_mod = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let mut priv_bytes = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut priv_bytes);
let d = BigUint::from_bytes_be(&priv_bytes);
let q = ec_mul(&d, &g, &a, &p_mod);
let qx = to_bytes_be_padded(&q.x, 20);
let qy = to_bytes_be_padded(&q.y, 20);
let mut pub_x = [0u8; 20];
let mut pub_y = [0u8; 20];
pub_x.copy_from_slice(&qx);
pub_y.copy_from_slice(&qy);
(priv_bytes, pub_x, pub_y)
}
// ── AES-CMAC (for MAC verification) ────────────────────────────────────────
/// AES-128-CMAC over 16 bytes of data.
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let cipher = Aes128::new(GenericArray::from_slice(key));
// For single-block CMAC:
// 1. Generate subkey K1
let mut l = GenericArray::clone_from_slice(&[0u8; 16]);
cipher.encrypt_block(&mut l);
let mut k1 = [0u8; 16];
let carry = (l[0] >> 7) & 1;
for i in 0..15 {
k1[i] = (l[i] << 1) | (l[i + 1] >> 7);
}
k1[15] = l[15] << 1;
if carry == 1 {
k1[15] ^= 0x87; // Rb for AES-128
}
// 2. XOR data with K1, encrypt
let mut block = [0u8; 16];
for i in 0..16 {
block[i] = data[i] ^ k1[i];
}
let mut ga = GenericArray::clone_from_slice(&block);
cipher.encrypt_block(&mut ga);
let mut mac = [0u8; 16];
mac.copy_from_slice(&ga);
mac
}
// ── SCSI command builders ───────────────────────────────────────────────────
/// Build REPORT KEY CDB (0xA4).
fn cdb_report_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[7] = crate::scsi::AACS_KEY_CLASS;
cdb[8] = (len >> 8) as u8;
cdb[9] = (len & 0xFF) as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
/// Build SEND KEY CDB (0xA3).
fn cdb_send_key(agid: u8, format: u8, len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_SEND_KEY;
cdb[7] = crate::scsi::AACS_KEY_CLASS;
cdb[8] = (len >> 8) as u8;
cdb[9] = (len & 0xFF) as u8;
cdb[10] = (agid << 6) | (format & 0x3F);
cdb
}
/// Build REPORT DISC STRUCTURE CDB (0xAD).
fn cdb_report_disc_structure(agid: u8, format: u8, len: u16) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
cdb[1] = 0x01; // Blu-ray
cdb[7] = format;
cdb[8] = (len >> 8) as u8;
cdb[9] = (len & 0xFF) as u8;
cdb[10] = agid << 6;
cdb
}
// ── High-level handshake ────────────────────────────────────────────────────
/// Result of a successful AACS authentication handshake.
#[derive(Debug)]
pub struct AacsAuth {
/// Bus key (16 bytes) — derived from ECDH
pub bus_key: [u8; 16],
/// AGID used for this session
pub agid: u8,
/// Volume ID (16 bytes) — read after auth
pub volume_id: Option<[u8; 16]>,
/// Read data key (16 bytes) — for AACS 2.0 bus decryption
pub read_data_key: Option<[u8; 16]>,
/// Drive certificate (92 bytes)
pub drive_cert: [u8; 92],
}
/// Perform the full AACS authentication handshake.
///
/// Requires a host private key (20 bytes) and host certificate (92 bytes)
/// from the KEYDB.cfg HC entry.
pub fn aacs_authenticate(
session: &mut DriveSession,
host_priv_key: &[u8; 20],
host_cert: &[u8],
) -> Result<AacsAuth> {
if host_cert.len() < 92 {
return Err(Error::AacsError { detail: "host certificate too short".into() });
}
// Step 1: Invalidate all AGIDs
for agid in 0..4u8 {
let cdb = cdb_report_key(agid, 0x3F, 2);
let _ = scsi_read(session, &cdb, 2);
}
// 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) })?;
let agid = (response[7] >> 6) & 0x03;
// Step 3: Generate host nonce and ephemeral key pair
let mut host_nonce = [0u8; 20];
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_nonce);
let (host_key, host_key_point_x, host_key_point_y) = generate_host_key_pair();
// Step 4: Send host certificate + nonce (SEND KEY format 0x01)
let mut send_buf = [0u8; 116];
send_buf[1] = 0x72; // data length
send_buf[4..24].copy_from_slice(&host_nonce);
send_buf[24..116].copy_from_slice(&host_cert[..92]);
let cdb = cdb_send_key(agid, 0x01, 116);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsError { detail: "drive rejected host certificate".into() })?;
// 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() })?;
let mut drive_nonce = [0u8; 20];
let mut drive_cert = [0u8; 92];
drive_nonce.copy_from_slice(&response[4..24]);
drive_cert.copy_from_slice(&response[24..116]);
// Detect AACS 2.0 drive certificate (type 0x11)
// AACS 2.0 drives use P-256/SHA-256 natively but accept AACS 1.0 host certs
// for backward compatibility. We proceed with AACS 1.0 handshake.
if drive_cert[0] == 0x11 {
// AACS 2.0 drive detected — falling back to AACS 1.0 handshake
// (full P-256 AACS 2.0 handshake not yet implemented)
// The drive should still accept our AACS 1.0 host certificate.
}
// Verify drive certificate (AACS 1.0 LA signature)
if drive_cert[0] == 0x01 && !verify_cert(&drive_cert) {
return Err(Error::AacsError { detail: "drive certificate verification failed".into() });
}
// Skip verification for AACS 2.0 certs (different LA key, P-256 curve)
// 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() })?;
let mut drive_key_point = [0u8; 40]; // x(20) + y(20)
let mut drive_key_sig = [0u8; 40]; // r(20) + s(20)
drive_key_point.copy_from_slice(&response[4..44]);
drive_key_sig.copy_from_slice(&response[44..84]);
// Verify drive key signature: sign(drive_nonce=host_nonce || drive_key_point)
let (drive_pub_x, drive_pub_y) = cert_pub_key(&drive_cert);
let mut verify_data = [0u8; 60];
verify_data[..20].copy_from_slice(&host_nonce);
verify_data[20..60].copy_from_slice(&drive_key_point);
let mut sig_r = [0u8; 20];
let mut sig_s = [0u8; 20];
sig_r.copy_from_slice(&drive_key_sig[..20]);
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() });
}
// Step 7: Sign host key point (ECDSA over drive_nonce || host_key_point)
let mut sign_data = [0u8; 60];
sign_data[..20].copy_from_slice(&drive_nonce);
sign_data[20..40].copy_from_slice(&host_key_point_x);
sign_data[40..60].copy_from_slice(&host_key_point_y);
let (host_sig_r, host_sig_s) = ecdsa_sign(host_priv_key, &sign_data);
// Step 8: Send host key point + signature (SEND KEY format 0x02)
let mut send_buf = [0u8; 84];
send_buf[1] = 0x52;
send_buf[4..24].copy_from_slice(&host_key_point_x);
send_buf[24..44].copy_from_slice(&host_key_point_y);
send_buf[44..64].copy_from_slice(&host_sig_r);
send_buf[64..84].copy_from_slice(&host_sig_s);
let cdb = cdb_send_key(agid, 0x02, 84);
scsi_write(session, &cdb, &send_buf)
.map_err(|_| Error::AacsError { detail: "drive rejected host key".into() })?;
// Step 9: Compute bus key via ECDH
let mut dkp_x = [0u8; 20];
let mut dkp_y = [0u8; 20];
dkp_x.copy_from_slice(&drive_key_point[..20]);
dkp_y.copy_from_slice(&drive_key_point[20..40]);
let bus_key = compute_bus_key(&host_key, &dkp_x, &dkp_y);
Ok(AacsAuth {
bus_key,
agid,
volume_id: None,
read_data_key: None,
drive_cert,
})
}
/// Read Volume ID after successful authentication.
pub fn read_volume_id(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<[u8; 16]> {
// 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() })?;
let mut vid = [0u8; 16];
let mut mac = [0u8; 16];
vid.copy_from_slice(&response[4..20]);
mac.copy_from_slice(&response[20..36]);
// 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() });
}
auth.volume_id = Some(vid);
Ok(vid)
}
/// Read data keys after successful authentication (for AACS 2.0 bus encryption).
pub fn read_data_keys(session: &mut DriveSession, auth: &mut AacsAuth) -> Result<([u8; 16], [u8; 16])> {
// 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() })?;
let mut enc_rdk = [0u8; 16];
let mut enc_wdk = [0u8; 16];
enc_rdk.copy_from_slice(&response[4..20]);
enc_wdk.copy_from_slice(&response[20..36]);
// Decrypt with bus key (AES-ECB)
let read_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_rdk);
let write_data_key = super::aes_ecb_decrypt(&auth.bus_key, &enc_wdk);
auth.read_data_key = Some(read_data_key);
Ok((read_data_key, write_data_key))
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ec_curve_generator_on_curve() {
// Verify G is on the curve: y² = x³ + ax + b (mod p)
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let b = BigUint::from_bytes_be(&EC_B);
let gx = BigUint::from_bytes_be(&EC_GX);
let gy = BigUint::from_bytes_be(&EC_GY);
let lhs = (&gy * &gy) % &p;
let rhs = (&gx * &gx * &gx + &a * &gx + &b) % &p;
assert_eq!(lhs, rhs, "Generator point is not on the curve");
}
#[test]
fn test_ec_mul_identity() {
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
// 1 × G = G
let result = ec_mul(&BigUint::one(), &g, &a, &p);
assert_eq!(result.x, g.x);
assert_eq!(result.y, g.y);
}
#[test]
fn test_ec_mul_order() {
// n × G = O (point at infinity)
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let n = BigUint::from_bytes_be(&EC_N);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let result = ec_mul(&n, &g, &a, &p);
assert!(result.infinity, "n × G should be point at infinity");
}
#[test]
fn test_ecdsa_sign_verify() {
// Generate a key pair and test sign/verify
let (priv_key, pub_x, pub_y) = generate_host_key_pair();
let data = b"test data for AACS ECDSA";
let (sig_r, sig_s) = ecdsa_sign(&priv_key, data);
assert!(ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, data),
"ECDSA signature should verify");
// Verify with wrong data fails
assert!(!ecdsa_verify(&pub_x, &pub_y, &sig_r, &sig_s, b"wrong data"),
"ECDSA should fail with wrong data");
}
#[test]
fn test_ecdh_shared_secret() {
// Two parties should derive the same shared point
let p = BigUint::from_bytes_be(&EC_P);
let a = BigUint::from_bytes_be(&EC_A);
let g = EcPoint::from_bytes(&EC_GX, &EC_GY);
let (priv_a, pub_ax, pub_ay) = generate_host_key_pair();
let (priv_b, pub_bx, pub_by) = generate_host_key_pair();
// A computes: priv_a × pub_B
let shared_a = compute_bus_key(&priv_a, &pub_bx, &pub_by);
// B computes: priv_b × pub_A
let shared_b = compute_bus_key(&priv_b, &pub_ax, &pub_ay);
assert_eq!(shared_a, shared_b, "ECDH shared secrets should match");
}
#[test]
fn test_aes_cmac() {
// Basic CMAC test — at minimum verify it produces consistent output
let key = [0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6,
0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c];
let data = [0u8; 16];
let mac1 = aes_cmac_16(&data, &key);
let mac2 = aes_cmac_16(&data, &key);
assert_eq!(mac1, mac2);
assert_ne!(mac1, [0u8; 16]); // shouldn't be all zeros
}
#[test]
fn test_verify_host_cert_from_keydb() {
// Verify the host cert from our KEYDB
let keydb_path = match std::env::var("KEYDB_PATH").ok() {
Some(p) => std::path::PathBuf::from(p),
None => return, // skip if KEYDB_PATH not set
};
if !keydb_path.exists() { return; }
let db = crate::aacs::KeyDb::load(&keydb_path).unwrap();
if let Some(hc) = &db.host_cert {
let valid = verify_cert(&hc.certificate);
eprintln!("Host cert verification: {}", if valid { "PASS" } else { "FAIL" });
// Note: our cert is revoked but should still have valid LA signature
// If it doesn't verify, the LA public key might be wrong
if !valid {
eprintln!(" (cert may use different LA key or format)");
}
}
}
}
+1447
View File
File diff suppressed because it is too large Load Diff
-159
View File
@@ -1,159 +0,0 @@
//! freemkv-info — Drive identification and compatibility checker.
//!
//! Sends standard SCSI INQUIRY and GET CONFIGURATION commands to an optical drive,
//! displays drive identity and compatibility status, and optionally outputs raw
//! response data for profile contribution.
//!
//! Usage:
//! freemkv-info /dev/sr0
//! freemkv-info /dev/sr0 --raw
//! freemkv-info /dev/sr0 --json
use std::env;
use std::path::Path;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("freemkv-info — Drive identification and compatibility checker");
eprintln!();
eprintln!("Usage: freemkv-info <device> [options]");
eprintln!();
eprintln!(" <device> Optical drive device (e.g. /dev/sr0)");
eprintln!(" --raw Output raw SCSI response hex (for profile contribution)");
eprintln!(" --json Output machine-readable JSON");
eprintln!(" --profiles Path to profiles directory (default: ./profiles)");
eprintln!();
eprintln!("Examples:");
eprintln!(" freemkv-info /dev/sr0");
eprintln!(" freemkv-info /dev/sr0 --raw > my_drive.txt");
process::exit(1);
}
let device = Path::new(&args[1]);
let raw_mode = args.iter().any(|a| a == "--raw");
let json_mode = args.iter().any(|a| a == "--json");
let profiles_dir = args.iter()
.position(|a| a == "--profiles")
.and_then(|i| args.get(i + 1))
.map(|s| s.as_str())
.unwrap_or("profiles");
// Open SCSI transport
let mut transport = match libfreemkv::scsi::open(device) {
Ok(t) => t,
Err(e) => {
eprintln!("Error: Cannot open {}: {}", device.display(), e);
process::exit(1);
}
};
// INQUIRY
let inquiry = match libfreemkv::scsi::inquiry(transport.as_mut()) {
Ok(i) => i,
Err(e) => {
eprintln!("Error: INQUIRY failed: {}", e);
process::exit(1);
}
};
// GET CONFIGURATION feature 0x010C
let gc_010c = libfreemkv::scsi::get_config_010c(transport.as_mut()).ok();
if json_mode {
print_json(&inquiry, &gc_010c);
} else if raw_mode {
print_raw(&inquiry, &gc_010c);
} else {
print_human(&inquiry, &gc_010c, profiles_dir);
}
}
fn print_human(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
profiles_dir: &str,
) {
println!("freemkv-info v{}", env!("CARGO_PKG_VERSION"));
println!();
println!("Drive: {} {} {}", inquiry.vendor_id, inquiry.model, inquiry.firmware);
println!("INQUIRY: additional_length=0x{:02X} ({})",
inquiry.raw.get(4).unwrap_or(&0),
inquiry.raw.get(4).unwrap_or(&0));
if let Some(gc) = gc_010c {
let data_hex: String = gc.iter().map(|b| format!("{:02x}", b)).collect();
println!("Feature 0x010C: {}", data_hex);
} else {
println!("Feature 0x010C: not available");
}
// Try to match profile
if let Ok(profiles) = libfreemkv::profile::load_all(Path::new(profiles_dir)) {
let matched = profiles.iter().find(|p| {
p.drive_id.contains(&inquiry.vendor_id)
&& p.drive_id.contains(&inquiry.model)
});
println!();
match matched {
Some(p) => {
println!("Profile: FOUND ({})", p.chipset.name());
println!("Raw Read: Supported");
}
None => {
println!("Profile: NOT FOUND");
println!("Raw Read: Unknown — run with --raw and submit a profile request");
}
}
} else {
println!();
println!("Profile: No profiles directory found at '{}'", profiles_dir);
}
}
fn print_raw(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
) {
println!("# freemkv-info raw output");
println!("# Submit this file to https://github.com/freemkv/libfreemkv/issues");
println!();
println!("vendor: {}", inquiry.vendor_id);
println!("model: {}", inquiry.model);
println!("firmware: {}", inquiry.firmware);
println!();
// Full INQUIRY hex
println!("inquiry_hex: {}", hex_encode(&inquiry.raw));
println!("inquiry_length: {}", inquiry.raw.len());
// GET CONFIG 0x010C
if let Some(gc) = gc_010c {
println!("get_config_010c_hex: {}", hex_encode(gc));
println!("get_config_010c_length: {}", gc.len());
} else {
println!("get_config_010c_hex: ERROR");
}
}
fn print_json(
inquiry: &libfreemkv::scsi::InquiryResult,
gc_010c: &Option<Vec<u8>>,
) {
let json = serde_json::json!({
"vendor": inquiry.vendor_id,
"model": inquiry.model,
"firmware": inquiry.firmware,
"inquiry_hex": hex_encode(&inquiry.raw),
"inquiry_length": inquiry.raw.len(),
"get_config_010c_hex": gc_010c.as_ref().map(|g| hex_encode(g)),
});
println!("{}", serde_json::to_string_pretty(&json).unwrap());
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{:02x}", b)).collect()
}
-99
View File
@@ -1,99 +0,0 @@
//! freemkv-test — Quick verification that raw disc access works.
//!
//! Enables raw read mode, calibrates speed, reads a few test sectors.
//! Use this to verify your drive and profile are working correctly.
//!
//! Usage:
//! freemkv-test /dev/sr0
//! freemkv-test /dev/sr0 --profiles ./profiles
use std::env;
use std::path::Path;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("freemkv-test — Verify raw disc access works");
eprintln!();
eprintln!("Usage: freemkv-test <device> [--profiles <dir>]");
process::exit(1);
}
let device = Path::new(&args[1]);
println!("freemkv-test v{}", env!("CARGO_PKG_VERSION"));
println!();
// Open drive session (uses bundled profiles)
print!("Opening {}... ", device.display());
let mut session = match libfreemkv::DriveSession::open(device) {
Ok(s) => { println!("OK"); s }
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
};
println!(" Drive ID: {}", session.profile.drive_id);
println!(" Chipset: {}", session.profile.chipset.name());
println!();
// Enable raw read mode
print!("Unlocking drive... ");
match session.unlock() {
Ok(()) => println!("OK"),
Err(e) => { println!("FAILED: {}", e); process::exit(1); }
}
// Check status
print!("Checking status... ");
match session.status() {
Ok(status) => {
if status.unlocked {
println!("OK (active)");
} else {
println!("WARNING: drive reported as locked");
}
}
Err(e) => println!("SKIP ({})", e),
}
// Calibrate speed
print!("Calibrating speed... ");
match session.calibrate() {
Ok(()) => println!("OK"),
Err(e) => println!("SKIP ({})", e),
}
// Read test sectors
let test_lbas: &[u32] = &[0, 100, 1000, 10000];
let mut buf = vec![0u8; 2048];
let mut pass = 0;
let mut fail = 0;
for &lba in test_lbas {
print!("Reading sector {}... ", lba);
match session.read_sectors(lba, 1, &mut buf) {
Ok(n) if n == 2048 => {
let nonzero = buf.iter().filter(|&&b| b != 0).count();
println!("OK ({} bytes, {} non-zero)", n, nonzero);
pass += 1;
}
Ok(n) => {
println!("PARTIAL ({} bytes)", n);
fail += 1;
}
Err(e) => {
println!("FAILED: {}", e);
fail += 1;
}
}
}
println!();
if fail == 0 {
println!("All {} checks passed. Drive is fully functional.", pass);
} else {
println!("{} passed, {} failed.", pass, fail);
process::exit(1);
}
}
+25 -4
View File
@@ -183,11 +183,32 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
return Ok((Vec::new(), Vec::new()));
}
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
// stream_PID: 16 bits → ep_map[2..4]
// reserved: 10 bits ┐
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
// num_EP_coarse: 16 bits │ (10+4+16+18+32 = 80)
// num_EP_fine: 18 bits │
// EP_map_start_address: 32 bits ┘
if ep_map.len() < 16 { return Ok((Vec::new(), Vec::new())); }
let _stream_pid = u16::from_be_bytes([ep_map[2], ep_map[3]]);
// ep_map[4..6] = reserved + EP stream type
let num_coarse = u16::from_be_bytes([ep_map[6], ep_map[7]]) as usize;
let num_fine = u32::from_be_bytes([ep_map[8], ep_map[9], ep_map[10], ep_map[11]]) as usize;
let ep_map_offset = u32::from_be_bytes([ep_map[12], ep_map[13], ep_map[14], ep_map[15]]) as usize;
// Read 10 bytes (80 bits) from ep_map[4..14] for bit extraction
// Use two u64s since we need 80 bits
let hi = u64::from_be_bytes([ep_map[4], ep_map[5], ep_map[6], ep_map[7],
ep_map[8], ep_map[9], ep_map[10], ep_map[11]]);
let lo_bytes = [ep_map[12], ep_map[13]];
// Bit 0-9: reserved (10)
// Bit 10-13: EP_stream_type (4)
// Bit 14-29: num_coarse (16)
// Bit 30-47: num_fine (18)
// Bit 48-79: EP_map_start (32) — bits 48-63 in hi, bits 64-79 in lo
let num_coarse = ((hi >> 34) & 0xFFFF) as usize;
let num_fine = ((hi >> 16) & 0x3FFFF) as usize;
let ep_map_offset = (((hi & 0xFFFF) as u32) << 16)
| (u16::from_be_bytes(lo_bytes) as u32);
let ep_map_offset = ep_map_offset as usize;
// EP map for this stream starts at ep_map_offset relative to ep_map start
if ep_map_offset + 4 > ep_map.len() {
+718 -60
View File
@@ -14,15 +14,67 @@ use crate::udf;
use crate::mpls;
use crate::clpi;
// ─── Public types ───────────────────────────────────────────────────────────
/// A scanned Blu-ray disc.
#[derive(Debug)]
pub struct Disc {
/// UDF Volume Identifier from Primary Volume Descriptor (always present)
pub volume_id: String,
/// Disc title from META/DL/bdmt_eng.xml (None if disc has no metadata)
pub meta_title: Option<String>,
/// Disc format (BD, UHD, DVD)
pub format: DiscFormat,
/// Disc capacity in sectors
pub capacity_sectors: u32,
/// Disc capacity in bytes
pub capacity_bytes: u64,
/// Number of layers (1 = single, 2 = dual)
pub layers: u8,
/// Titles sorted by duration (longest first), then playlist name
pub titles: Vec<Title>,
/// Disc region
pub region: DiscRegion,
/// AACS state — None if disc is unencrypted or keys unavailable
pub aacs: Option<AacsState>,
/// Whether this disc requires AACS decryption
pub encrypted: bool,
}
/// Disc format.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DiscFormat {
/// 4K UHD Blu-ray (HEVC 2160p)
Uhd,
/// Standard Blu-ray (1080p/1080i)
BluRay,
/// DVD
Dvd,
/// Unknown
Unknown,
}
/// Disc playback region.
#[derive(Debug, Clone, PartialEq)]
pub enum DiscRegion {
/// Region-free (all UHD discs, some BD/DVD)
Free,
/// Blu-ray regions (A/B/C or combination)
BluRay(Vec<BdRegion>),
/// DVD regions (1-8 or combination)
Dvd(Vec<u8>),
}
/// Blu-ray region codes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BdRegion {
/// Region A/1 — Americas, East Asia (Japan, Korea, Southeast Asia)
A,
/// Region B/2 — Europe, Africa, Australia, Middle East
B,
/// Region C/3 — Central/South Asia, China, Russia
C,
}
/// A title (one MPLS playlist).
@@ -36,49 +88,88 @@ pub struct Title {
pub duration_secs: f64,
/// Total size in bytes
pub size_bytes: u64,
/// Number of clips
pub clip_count: usize,
/// Clip references in playback order
pub clips: Vec<Clip>,
/// All streams (video, audio, subtitle, etc.)
pub streams: Vec<Stream>,
/// Sector extents for ripping (clip LBA ranges)
pub extents: Vec<Extent>,
}
/// A clip reference within a title.
#[derive(Debug, Clone)]
pub struct Clip {
/// Clip filename without extension (e.g. "00001")
pub clip_id: String,
/// In-time in 45kHz ticks
pub in_time: u32,
/// Out-time in 45kHz ticks
pub out_time: u32,
/// Duration in seconds
pub duration_secs: f64,
/// Source packet count (from CLPI, 0 if unavailable)
pub source_packets: u32,
}
/// A stream within a title.
#[derive(Debug, Clone)]
pub struct Stream {
/// Stream type
pub kind: StreamKind,
pub enum Stream {
Video(VideoStream),
Audio(AudioStream),
Subtitle(SubtitleStream),
}
/// A video stream.
#[derive(Debug, Clone)]
pub struct VideoStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec
/// Codec (HEVC, H.264, VC-1, MPEG-2)
pub codec: Codec,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Video resolution (e.g. "2160p", "1080p")
/// Resolution (e.g. "2160p", "1080p", "1080i")
pub resolution: String,
/// Frame rate (e.g. "23.976")
/// Frame rate (e.g. "23.976", "25")
pub frame_rate: String,
/// Channel layout (e.g. "5.1", "7.1", "stereo")
pub channels: String,
/// Sample rate (e.g. "48kHz")
pub sample_rate: String,
/// HDR format
pub hdr: HdrFormat,
/// Color space
pub color_space: ColorSpace,
/// Whether this is a secondary/enhancement stream
/// Whether this is a secondary stream (PiP, Dolby Vision EL)
pub secondary: bool,
/// Extra label (e.g. "Dolby Vision EL")
pub label: String,
}
/// Stream type.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StreamKind {
Video,
Audio,
Subtitle,
/// An audio stream.
#[derive(Debug, Clone)]
pub struct AudioStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (TrueHD, DTS-HD MA, DD, LPCM, etc.)
pub codec: Codec,
/// Channel layout (e.g. "5.1", "7.1", "stereo", "mono")
pub channels: String,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Sample rate (e.g. "48kHz", "96kHz")
pub sample_rate: String,
/// Whether this is a secondary stream (commentary)
pub secondary: bool,
/// Extra label
pub label: String,
}
/// A subtitle stream.
#[derive(Debug, Clone)]
pub struct SubtitleStream {
/// MPEG-TS packet ID
pub pid: u16,
/// Codec (PGS)
pub codec: Codec,
/// ISO 639-2 language code (e.g. "eng", "fra")
pub language: String,
/// Whether this is a forced subtitle
pub forced: bool,
}
/// Video/audio codec.
@@ -206,56 +297,623 @@ impl Title {
}
}
impl Stream {
/// Human-readable one-line description.
pub fn display(&self) -> String {
match self.kind {
StreamKind::Video => {
let mut parts = vec![self.codec.name().to_string()];
if !self.resolution.is_empty() { parts.push(self.resolution.clone()); }
if !self.frame_rate.is_empty() { parts.push(format!("{}fps", self.frame_rate)); }
if self.hdr != HdrFormat::Sdr { parts.push(self.hdr.name().to_string()); }
if self.color_space != ColorSpace::Unknown && self.color_space != ColorSpace::Bt709 {
parts.push(self.color_space.name().to_string());
}
if self.secondary { parts.push(format!("[{}]", self.label)); }
parts.join(" ")
}
StreamKind::Audio => {
let mut parts = vec![self.codec.name().to_string()];
if !self.channels.is_empty() { parts.push(self.channels.clone()); }
if !self.sample_rate.is_empty() { parts.push(self.sample_rate.clone()); }
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
if self.secondary { parts.push("[secondary]".to_string()); }
parts.join(" ")
}
StreamKind::Subtitle => {
let mut parts = vec![self.codec.name().to_string()];
if !self.language.is_empty() { parts.push(format!("({})", self.language)); }
parts.join(" ")
}
}
}
/// Kind as a display string
pub fn kind_name(&self) -> &'static str {
match self.kind {
StreamKind::Video => "Video",
StreamKind::Audio => "Audio",
StreamKind::Subtitle => "Subtitle",
// ─── AACS state ─────────────────────────────────────────────────────────────
/// AACS decryption state for a disc.
#[derive(Debug)]
pub struct AacsState {
/// AACS version (1 or 2)
pub version: u8,
/// Whether bus encryption is enabled (always true for AACS 2.0 / UHD)
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
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
/// Volume Unique Key (16 bytes)
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
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) — from SCSI handshake
pub volume_id: [u8; 16],
}
/// How AACS keys were resolved.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
/// VUK found directly in KEYDB by disc hash
KeyDb,
/// Media key + Volume ID from KEYDB → derived VUK
KeyDbDerived,
/// MKB + processing keys → media key → VUK
ProcessingKey,
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
}
impl KeySource {
pub fn name(&self) -> &'static str {
match self {
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::DeviceKey => "MKB + device key",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
// Placeholder — the actual implementation will be wired in
// when the CLI's disc_info.rs parsing is migrated here.
// For now, the CLI does its own parsing.
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // relative to $HOME
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
/// Options for disc scanning.
pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
}
impl Default for ScanOptions {
fn default() -> Self {
ScanOptions { keydb_path: None }
}
}
impl ScanOptions {
/// Create options with a specific KEYDB path.
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
ScanOptions { keydb_path: Some(path.into()) }
}
/// Resolve KEYDB path: explicit path first, then standard locations.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if let Some(p) = &self.keydb_path {
if p.exists() { return Some(p.clone()); }
}
if let Some(home) = std::env::var_os("HOME") {
for relative in KEYDB_SEARCH_PATHS {
let p = std::path::PathBuf::from(&home).join(relative);
if p.exists() { return Some(p); }
}
}
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
if p.exists() { return Some(p); }
None
}
}
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.
///
/// 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());
/// }
/// ```
pub fn scan(session: &mut DriveSession, opts: &ScanOptions) -> Result<Self> {
// Step 1: Read capacity
let capacity = Self::read_capacity(session)?;
// Step 2: Parse UDF filesystem
let udf_fs = udf::read_filesystem(session)?;
// Step 3: Find and parse MPLS playlists
let mut titles = Vec::new();
if let Some(playlist_dir) = udf_fs.find_dir("/BDMV/PLAYLIST") {
for entry in &playlist_dir.entries {
if !entry.is_dir && entry.name.to_lowercase().ends_with(".mpls") {
let path = format!("/BDMV/PLAYLIST/{}", entry.name);
if let Ok(mpls_data) = udf_fs.read_file(session, &path) {
if let Some(title) = Self::parse_playlist(session, &udf_fs, &entry.name, &mpls_data) {
titles.push(title);
}
}
}
}
}
// Sort: longest first
titles.sort_by(|a, b| b.duration_secs.partial_cmp(&a.duration_secs).unwrap_or(std::cmp::Ordering::Equal));
// Step 4: Read disc title from META/DL/bdmt_eng.xml
let meta_title = Self::read_meta_title(session, &udf_fs);
// Step 5: Enhance streams with disc config file labels (if available)
crate::labels::apply(session, &udf_fs, &mut titles);
// Step 6: Detect AACS encryption
let encrypted = udf_fs.find_dir("/AACS").is_some()
|| udf_fs.find_dir("/BDMV/AACS").is_some();
// Step 7: If encrypted and KEYDB available, authenticate and derive keys
let aacs = if encrypted {
if let Some(keydb_path) = opts.resolve_keydb() {
match Self::setup_aacs(session, &keydb_path) {
Ok(state) => Some(state),
Err(_) => None, // keys not found, continue without decryption
}
} else {
None
}
} else {
None
};
// Derive disc format from main title video codec
let format = Self::detect_format(&titles);
// Derive layer count from capacity
// BD-25 single layer: up to ~12M sectors (~25GB)
// BD-50 dual layer: ~12M-25M sectors (~50GB)
// BD-66/100 UHD: 25M+ sectors
let layers = if capacity > 24_000_000 { 2 } else { 1 };
// UHD is always region-free. BD/DVD region parsing TODO.
let region = if format == DiscFormat::Uhd {
DiscRegion::Free
} else {
DiscRegion::Free // TODO: parse from index.bdmv
};
Ok(Disc {
volume_id: udf_fs.volume_id.clone(),
meta_title: meta_title,
format,
capacity_sectors: capacity,
capacity_bytes: capacity as u64 * 2048,
layers,
titles,
region,
aacs,
encrypted,
})
}
/// Set up AACS decryption for this disc.
/// Call after scan() to enable transparent content decryption.
pub fn setup_aacs(
session: &mut DriveSession,
keydb_path: &std::path::Path,
) -> Result<AacsState> {
use crate::aacs::{self, KeyDb};
use crate::aacs::handshake;
// Load KEYDB
let keydb = KeyDb::load(keydb_path).map_err(|e| Error::AacsError {
detail: format!("failed to load KEYDB: {}", e),
})?;
// Step 1: Try SCSI handshake for Volume ID + read_data_key
// Open a separate transport (AACS auth must happen before raw mode).
// If handshake fails (drive doesn't support AACS layer, e.g. raw-mode drives),
// fall back to disc-hash-only KEYDB lookup.
let device_path = session.device_path().to_string();
let mut vid: Option<[u8; 16]> = None;
let mut read_data_key: Option<[u8; 16]> = None;
if !device_path.is_empty() {
if let Ok(mut aacs_session) = DriveSession::open_no_unlock(std::path::Path::new(&device_path)) {
if let Ok(hc) = keydb.host_cert.as_ref().ok_or(()) {
if let Ok(mut auth) = handshake::aacs_authenticate(
&mut aacs_session, &hc.private_key, &hc.certificate,
) {
vid = handshake::read_volume_id(&mut aacs_session, &mut auth).ok();
read_data_key = handshake::read_data_keys(&mut aacs_session, &mut auth)
.ok().map(|(rdk, _)| rdk);
}
}
}
// Handshake failure is not fatal — we can still resolve via disc hash
}
// Step 2: Read Unit_Key_RO.inf from disc via UDF (uses the unlocked main session)
let udf_fs = udf::read_filesystem(session)?;
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: "failed to read Unit_Key_RO.inf from disc".into(),
})?;
// Step 3: Read Content Certificate (optional — for AACS version detection)
let cc_data = udf_fs.read_file(session, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(session, "/AACS/Content001.cer"))
.ok();
// Step 4: Resolve keys
// If we have VID from handshake, use full 4-path chain.
// If no VID (handshake failed), use disc-hash-only KEYDB lookup.
let mkb_data = aacs::read_mkb_from_drive(session).ok();
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
// Use a zero VID placeholder if handshake failed — resolve_keys
// will still work via disc hash (path 1)
let vid_for_resolve = vid.unwrap_or([0u8; 16]);
let resolved = aacs::resolve_keys(
&uk_ro_data,
cc_data.as_deref(),
&vid_for_resolve,
&keydb,
mkb_data.as_deref(),
).ok_or_else(|| Error::AacsError {
detail: "failed to resolve AACS keys — disc not in KEYDB".into(),
})?;
let key_source = match resolved.key_source {
1 => KeySource::KeyDb,
2 => KeySource::KeyDbDerived,
3 => KeySource::ProcessingKey,
4 => KeySource::DeviceKey,
_ => KeySource::KeyDb,
};
Ok(AacsState {
version: if resolved.aacs2 { 2 } else { 1 },
bus_encryption: resolved.bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
key_source,
vuk: resolved.vuk,
unit_keys: resolved.unit_keys,
read_data_key,
volume_id: vid.unwrap_or([0u8; 16]),
})
}
// ── Internal helpers ────────────────────────────────────────────────────
/// Detect disc format from the main title's video streams.
fn detect_format(titles: &[Title]) -> DiscFormat {
for title in titles.iter().take(3) {
for stream in &title.streams {
if let Stream::Video(v) = stream {
if v.resolution.contains("2160") {
return DiscFormat::Uhd;
}
if v.resolution.contains("1080") || v.resolution.contains("720") {
return DiscFormat::BluRay;
}
if v.resolution.contains("480") || v.resolution.contains("576") {
return DiscFormat::Dvd;
}
}
}
}
DiscFormat::Unknown
}
/// Read disc title from META/DL/bdmt_eng.xml (Blu-ray Disc Meta Table).
/// Prefers English, falls back to first available language.
/// Returns None if META directory is empty or XML has no usable title.
fn read_meta_title(session: &mut DriveSession, udf_fs: &udf::UdfFs) -> Option<String> {
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
for sub in &meta_dir.entries {
if !sub.is_dir { continue; }
let dl_path = format!("/BDMV/META/{}", sub.name);
if let Some(dl_dir) = udf_fs.find_dir(&dl_path) {
let xml_files: Vec<_> = dl_dir.entries.iter()
.filter(|e| !e.is_dir && e.name.to_lowercase().ends_with(".xml"))
.collect();
let eng = xml_files.iter().find(|e| e.name.to_lowercase().contains("eng"));
let target = eng.or_else(|| xml_files.first());
if let Some(entry) = target {
let path = format!("{}/{}", dl_path, entry.name);
if let Ok(data) = udf_fs.read_file(session, &path) {
let xml = String::from_utf8_lossy(&data);
if let Some(start) = xml.find("<di:name>") {
let s = start + "<di:name>".len();
if let Some(end) = xml[s..].find("</di:name>") {
let title = xml[s..s + end].trim().to_string();
if !title.is_empty() && title != "Blu-ray" {
return Some(title);
}
}
}
}
}
}
}
None
}
fn read_capacity(session: &mut DriveSession) -> Result<u32> {
let cdb = [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut buf = [0u8; 8];
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000)?;
let lba = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]);
Ok(lba + 1)
}
fn parse_playlist(
session: &mut DriveSession,
udf_fs: &udf::UdfFs,
filename: &str,
data: &[u8],
) -> Option<Title> {
let parsed = mpls::parse(data).ok()?;
// Calculate duration from play items
let duration_ticks: u64 = parsed.play_items.iter()
.map(|pi| (pi.out_time.saturating_sub(pi.in_time)) as u64)
.sum();
let duration_secs = duration_ticks as f64 / 45000.0;
// Skip very short playlists (< 30 seconds)
if duration_secs < 30.0 {
return None;
}
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
let mut clips = Vec::with_capacity(parsed.play_items.len());
for play_item in &parsed.play_items {
let clip_dur = play_item.out_time.saturating_sub(play_item.in_time) as f64 / 45000.0;
let mut pkt_count: u32 = 0;
let clpi_path = format!("/BDMV/CLIPINF/{}.clpi", play_item.clip_id);
if let Ok(clpi_data) = udf_fs.read_file(session, &clpi_path) {
if let Ok(clip_info) = clpi::parse(&clpi_data) {
pkt_count = clip_info.source_packet_count;
total_size += pkt_count as u64 * 192;
let clip_extents = clip_info.get_extents(play_item.in_time, play_item.out_time);
extents.extend(clip_extents);
}
}
clips.push(Clip {
clip_id: play_item.clip_id.clone(),
in_time: play_item.in_time,
out_time: play_item.out_time,
duration_secs: clip_dur,
source_packets: pkt_count,
});
}
// Build streams from STN table
let streams: Vec<Stream> = parsed.streams.iter().map(|s| {
let codec = Codec::from_coding_type(s.coding_type);
match s.stream_type {
1 | 6 | 7 => Stream::Video(VideoStream {
pid: s.pid,
codec,
resolution: format_resolution(s.video_format, s.video_rate),
frame_rate: format_framerate(s.video_rate),
hdr: match s.dynamic_range {
1 => HdrFormat::Hdr10,
2 => HdrFormat::DolbyVision,
_ => HdrFormat::Sdr,
},
color_space: match s.color_space {
1 => ColorSpace::Bt709,
2 => ColorSpace::Bt2020,
_ => ColorSpace::Unknown,
},
secondary: s.secondary,
label: match s.stream_type {
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 {
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(),
}),
}
}).collect();
let playlist_num = filename.trim_end_matches(".mpls").trim_end_matches(".MPLS");
let playlist_id = playlist_num.parse::<u16>().unwrap_or(0);
Some(Title {
playlist: filename.to_string(),
playlist_id,
duration_secs,
size_bytes: total_size,
clips,
streams,
extents,
})
}
}
// ─── Decrypted reader ──────────────────────────────────────────────────────
/// A reader that reads m2ts content, decrypting transparently if needed.
pub struct ContentReader<'a> {
session: &'a mut DriveSession,
aacs: Option<&'a AacsState>,
extents: Vec<Extent>,
current_extent: usize,
current_offset: u32, // sectors into current extent
unit_key_idx: usize,
}
impl 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()),
})?;
Ok(ContentReader {
session,
aacs: self.aacs.as_ref(),
extents: title.extents.clone(),
current_extent: 0,
current_offset: 0,
unit_key_idx: 0,
})
}
}
impl<'a> ContentReader<'a> {
/// Read the next aligned unit (6144 bytes).
/// Automatically decrypted if AACS keys are available.
/// Returns None when all extents are exhausted.
pub fn read_unit(&mut self) -> Result<Option<Vec<u8>>> {
if self.current_extent >= self.extents.len() {
return Ok(None);
}
let extent = &self.extents[self.current_extent];
let lba = extent.start_lba + self.current_offset;
// Read 3 sectors (one aligned unit)
let mut unit = vec![0u8; crate::aacs::ALIGNED_UNIT_LEN];
for i in 0..3u32 {
let offset = (i as usize) * 2048;
let mut sector = [0u8; 2048];
session_read_sector(self.session, lba + i, &mut sector)?;
unit[offset..offset + 2048].copy_from_slice(&sector);
}
// Decrypt if needed
if let Some(aacs) = &self.aacs {
if crate::aacs::is_unit_encrypted(&unit) {
let uk = aacs.unit_keys.get(self.unit_key_idx)
.map(|(_, k)| *k)
.unwrap_or([0u8; 16]);
crate::aacs::decrypt_unit_full(
&mut unit,
&uk,
aacs.read_data_key.as_ref(),
);
}
}
// Advance position
self.current_offset += 3;
if self.current_offset >= extent.sector_count {
self.current_extent += 1;
self.current_offset = 0;
}
Ok(Some(unit))
}
}
fn session_read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8; 2048]) -> Result<()> {
let cdb = [
crate::scsi::SCSI_READ_10, 0x00,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00, 0x00, 0x01, 0x00,
];
session.scsi_execute(&cdb, crate::scsi::DataDirection::FromDevice, buf, 10_000)?;
Ok(())
}
// ─── Format helpers ────────────────────────────────────────────────────────
fn format_resolution(video_format: u8, _video_rate: u8) -> String {
match video_format {
1 => "480i".into(),
2 => "576i".into(),
3 => "480p".into(),
4 => "1080i".into(),
5 => "720p".into(),
6 => "1080p".into(),
7 => "576p".into(),
8 => "2160p".into(),
_ => String::new(),
}
}
fn format_framerate(video_rate: u8) -> String {
match video_rate {
1 => "23.976".into(),
2 => "24".into(),
3 => "25".into(),
4 => "29.97".into(),
6 => "50".into(),
7 => "59.94".into(),
_ => String::new(),
}
}
fn format_channels(audio_format: u8) -> String {
match audio_format {
1 => "mono".into(),
3 => "stereo".into(),
6 => "5.1".into(),
12 => "7.1".into(),
_ if audio_format > 0 => format!("{}ch", audio_format),
_ => String::new(),
}
}
fn format_samplerate(audio_rate: u8) -> String {
match audio_rate {
1 => "48kHz".into(),
4 => "96kHz".into(),
5 => "192kHz".into(),
12 => "48/192kHz".into(),
14 => "48/96kHz".into(),
_ => String::new(),
}
}
+101 -48
View File
@@ -1,8 +1,13 @@
//! High-level drive session — the main API for consumers.
//! Drive session — open, identify, unlock, and read from optical drives.
//!
//! Opens a drive, identifies it via standard SCSI commands,
//! matches it against the profile database, and provides
//! raw disc access methods.
//! `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.
use std::path::Path;
use crate::error::{Error, Result};
@@ -12,28 +17,41 @@ use crate::profile::{self, DriveProfile, Chipset};
use crate::platform::{Platform, DriveStatus};
use crate::platform::mt1959::Mt1959;
/// A complete drive session.
/// A drive session with identification, platform, and SCSI transport.
///
/// Handles: identify → match profile → create platform → execute commands.
/// 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,
pub drive_id: DriveId,
device_path: String,
}
impl DriveSession {
/// Open a drive, identify it, and find the matching profile.
/// Uses the bundled profile database — no external files needed.
/// Open a drive, identify it, match a profile, and unlock for raw reads.
///
/// This is the standard entry point. After `open()`, the drive is ready
/// for sector reads, disc scanning, and content extraction.
pub fn open(device: &Path) -> Result<Self> {
let mut session = Self::open_no_unlock(device)?;
session.wait_ready()?;
let _ = session.unlock(); // silently ignore — unencrypted discs don't need it
Ok(session)
}
/// Open a drive WITHOUT unlocking.
///
/// Used when AACS authentication must happen before raw mode.
/// The AACS SCSI handshake requires the drive's standard firmware
/// state — unlocking puts the drive in vendor-specific raw mode
/// which disables the AACS layer.
pub fn open_no_unlock(device: &Path) -> Result<Self> {
let mut transport = crate::scsi::open(device)?;
let profiles = profile::load_bundled()?;
// Identify drive via standard SCSI commands
// SPC-4 §6.4 (INQUIRY) + MMC-6 §5.3.10 (Feature 010Ch)
let drive_id = DriveId::from_drive(transport.as_mut())?;
// Match drive to a profile by INQUIRY fields
let profile = profile::find_by_drive_id(&profiles, &drive_id)
.cloned()
.ok_or_else(|| Error::UnsupportedDrive {
@@ -42,59 +60,61 @@ impl DriveSession {
product_revision: drive_id.product_revision.trim().to_string(),
})?;
let platform: Box<dyn Platform> = match profile.chipset {
Chipset::MediaTek => {
Box::new(Mt1959::new(profile.clone()))
}
Chipset::Renesas => {
return Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: "Renesas not yet implemented".to_string(),
});
}
};
let platform = create_platform(&profile, &drive_id)?;
Ok(DriveSession {
scsi: transport,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
})
}
/// Open with an explicit profile (skip auto-detection).
/// 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: Box<dyn Platform> = match profile.chipset {
Chipset::MediaTek => {
Box::new(Mt1959::new(profile.clone()))
}
Chipset::Renesas => {
return Err(Error::UnsupportedDrive {
vendor_id: drive_id.vendor_id.trim().to_string(),
product_id: drive_id.product_id.trim().to_string(),
product_revision: "Renesas not yet implemented".to_string(),
});
}
};
let platform = create_platform(&profile, &drive_id)?;
Ok(DriveSession {
scsi: transport,
platform,
profile,
drive_id,
device_path: device.to_string_lossy().to_string(),
})
}
/// Activate raw disc access mode.
/// 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
for _ in 0..60 {
let mut buf = [0u8; 0];
if self.scsi.as_mut().execute(
&tur, crate::scsi::DataDirection::None, &mut buf, 5000
).is_ok() {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(Error::DeviceNotFound {
path: format!("{}: drive not ready after 30s", self.device_path),
})
}
/// Device path this session was opened on.
pub fn device_path(&self) -> &str {
&self.device_path
}
/// Activate raw disc access mode (vendor-specific unlock).
pub fn unlock(&mut self) -> Result<()> {
self.platform.unlock(self.scsi.as_mut())
}
/// Check if raw disc access mode is enabled.
/// Check if raw disc access mode is active.
pub fn is_unlocked(&self) -> bool {
self.platform.is_unlocked()
}
@@ -119,33 +139,66 @@ impl DriveSession {
self.platform.calibrate(self.scsi.as_mut())
}
/// Read raw disc sectors.
/// Read raw disc sectors via platform-specific command.
pub fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.platform.read_sectors(self.scsi.as_mut(), lba, count, buf)
}
/// Generic probe command.
/// Platform-specific probe command.
pub fn probe(&mut self, sub_cmd: u8, address: u32, length: u32) -> Result<Vec<u8>> {
self.platform.probe(self.scsi.as_mut(), sub_cmd, address, length)
}
/// Standard READ(10) — reads unencrypted sectors (UDF filesystem, etc).
/// Does not require unlock. Use read_sectors() for raw/encrypted content.
/// Standard 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 = [
0x28, 0x00, // READ(10), no flags
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,
];
let result = self.scsi.as_mut().execute(
&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)?;
&cdb, crate::scsi::DataDirection::FromDevice, buf, 5_000)?;
Ok(result.bytes_transferred)
}
/// Send a raw SCSI CDB. Used by UDF reader and disc structure parsers.
pub fn scsi_execute(&mut self, cdb: &[u8], direction: crate::scsi::DataDirection, buf: &mut [u8], timeout_ms: u32) -> Result<crate::scsi::ScsiResult> {
/// 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 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,
) -> Result<crate::scsi::ScsiResult> {
self.scsi.as_mut().execute(cdb, direction, buf, timeout_ms)
}
}
/// 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(),
product_revision: "Renesas not yet implemented".to_string(),
}),
}
}
+83 -60
View File
@@ -1,97 +1,120 @@
/// libfreemkv error codes.
///
/// The library returns structured error codes with context data.
/// Applications are responsible for formatting user-facing messages.
/// This keeps the library locale-independent and testable.
//! 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.
//!
//! # Error Code Ranges
//!
//! | Range | Category |
//! |-------|----------|
//! | E1xxx | Device errors |
//! | E2xxx | Profile errors |
//! | E3xxx | Unlock errors |
//! | E4xxx | SCSI errors |
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
/// Error code table.
///
/// | Code | Name | Meaning |
/// |------|------|---------|
/// | 1000 | DeviceNotFound | Device path doesn't exist or can't be opened |
/// | 1001 | DevicePermission | Device exists but permission denied |
/// | 2000 | UnsupportedDrive | Drive not in profile database |
/// | 2001 | ProfileNotFound | Specific firmware version not in database |
/// | 2002 | ProfileParse | Profile database is malformed |
/// | 3000 | UnlockFailed | Drive rejected unlock command |
/// | 3001 | SignatureMismatch | Wrong signature returned by drive |
/// | 3002 | NotUnlocked | Raw read attempted before unlock |
/// | 3003 | NotCalibrated | Raw read attempted before calibrate |
/// | 4000 | ScsiError | SCSI command failed |
/// | 4001 | ScsiTimeout | SCSI command timed out |
/// | 5000 | IoError | OS-level I/O error |
// ── Error codes (single source of truth) ────────────────────────────────────
pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
pub const E_PROFILE_NOT_FOUND: u16 = 2001;
pub const E_PROFILE_PARSE: u16 = 2002;
pub const E_UNLOCK_FAILED: u16 = 3000;
pub const E_SIGNATURE_MISMATCH: u16 = 3001;
pub const E_NOT_UNLOCKED: u16 = 3002;
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;
// ── Error enum ──────────────────────────────────────────────────────────────
/// Structured error with numeric code and context data.
#[derive(Debug)]
pub enum Error {
// 1xxx — Device errors
DeviceNotFound { path: String },
DevicePermission { path: String },
// 2xxx — Profile errors
UnsupportedDrive { vendor_id: String, product_id: String, product_revision: String },
ProfileNotFound { vendor_id: String, product_revision: String, vendor_specific: String },
ProfileParse { detail: String },
// 3xxx — Unlock errors
UnlockFailed { detail: String },
SignatureMismatch { expected: [u8; 4], got: [u8; 4] },
NotUnlocked,
NotCalibrated,
// 4xxx — SCSI errors
ScsiError { opcode: u8, status: u8, sense_key: u8 },
ScsiTimeout { opcode: u8 },
// 5xxx — I/O errors
IoError { source: std::io::Error },
// 6xxx — Disc format errors
DiscError { detail: String },
AacsError { detail: String },
}
impl Error {
/// Numeric error code for programmatic handling.
/// Numeric error code.
pub fn code(&self) -> u16 {
match self {
Error::DeviceNotFound { .. } => 1000,
Error::DevicePermission { .. } => 1001,
Error::UnsupportedDrive { .. } => 2000,
Error::ProfileNotFound { .. } => 2001,
Error::ProfileParse { .. } => 2002,
Error::UnlockFailed { .. } => 3000,
Error::SignatureMismatch { .. } => 3001,
Error::NotUnlocked => 3002,
Error::NotCalibrated => 3003,
Error::ScsiError { .. } => 4000,
Error::ScsiTimeout { .. } => 4001,
Error::IoError { .. } => 5000,
Error::DiscError { .. } => 6000,
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::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,
}
}
}
/// Default Display — terse, for logs. Applications should format their own messages.
/// Display format: "E{code}: {context}" — terse, for logs.
/// Applications should format their own user-facing messages using code() and fields.
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::DeviceNotFound { path } => write!(f, "E1000: device not found: {path}"),
Error::DevicePermission { path } => write!(f, "E1001: permission denied: {path}"),
Error::DeviceNotFound { path } =>
write!(f, "E{}: {}", E_DEVICE_NOT_FOUND, path),
Error::DevicePermission { path } =>
write!(f, "E{}: {}", E_DEVICE_PERMISSION, path),
Error::UnsupportedDrive { vendor_id, product_id, product_revision } =>
write!(f, "E2000: unsupported drive: {} {} {}", vendor_id.trim(), product_id.trim(), product_revision.trim()),
write!(f, "E{}: {} {} {}", E_UNSUPPORTED_DRIVE,
vendor_id.trim(), product_id.trim(), product_revision.trim()),
Error::ProfileNotFound { vendor_id, product_revision, vendor_specific } =>
write!(f, "E2001: no profile: {} {} {}", vendor_id.trim(), product_revision.trim(), vendor_specific.trim()),
Error::ProfileParse { detail } => write!(f, "E2002: profile parse: {detail}"),
Error::UnlockFailed { detail } => write!(f, "E3000: unlock failed: {detail}"),
write!(f, "E{}: {} {} {}", E_PROFILE_NOT_FOUND,
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, "E3001: signature mismatch: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
write!(f, "E{}: expected {:02x}{:02x}{:02x}{:02x} got {:02x}{:02x}{:02x}{:02x}",
E_SIGNATURE_MISMATCH,
expected[0], expected[1], expected[2], expected[3],
got[0], got[1], got[2], got[3]),
Error::NotUnlocked => write!(f, "E3002: not unlocked"),
Error::NotCalibrated => write!(f, "E3003: not calibrated"),
Error::NotUnlocked =>
write!(f, "E{}", E_NOT_UNLOCKED),
Error::NotCalibrated =>
write!(f, "E{}", E_NOT_CALIBRATED),
Error::ScsiError { opcode, status, sense_key } =>
write!(f, "E4000: SCSI 0x{opcode:02x} failed: status=0x{status:02x} sense=0x{sense_key:02x}"),
Error::ScsiTimeout { opcode } => write!(f, "E4001: SCSI 0x{opcode:02x} timeout"),
Error::IoError { source } => write!(f, "E5000: {source}"),
Error::DiscError { detail } => write!(f, "E6000: disc: {detail}"),
write!(f, "E{}: opcode=0x{:02x} status=0x{:02x} sense=0x{:02x}",
E_SCSI_ERROR, opcode, status, sense_key),
Error::ScsiTimeout { opcode } =>
write!(f, "E{}: opcode=0x{:02x}", E_SCSI_TIMEOUT, 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),
}
}
}
+24 -1
View File
@@ -39,6 +39,9 @@ pub struct DriveId {
/// Format: CCYYMMDDHHMI (12 ASCII characters)
pub firmware_date: String,
/// Drive serial number — GET CONFIGURATION Feature 0108h
pub serial_number: String,
/// Raw 96-byte INQUIRY response for additional parsing if needed.
pub raw_inquiry: Vec<u8>,
}
@@ -63,10 +66,29 @@ impl DriveId {
String::new()
};
Ok(Self::from_inquiry(&inquiry, &firmware_date))
// GET CONFIGURATION Feature 0108h — Serial Number
let mut gc_serial = vec![0u8; 256];
let cdb_serial = [0x46, 0x02, 0x01, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
let serial_number = if let Ok(r) = transport.execute(&cdb_serial, DataDirection::FromDevice, &mut gc_serial, 5000) {
if r.bytes_transferred > 12 {
String::from_utf8_lossy(&gc_serial[12..r.bytes_transferred])
.trim().to_string()
} else { String::new() }
} else { String::new() };
Ok(DriveId {
vendor_id: ascii_field(&inquiry, 8, 16),
product_id: ascii_field(&inquiry, 16, 32),
product_revision: ascii_field(&inquiry, 32, 36),
vendor_specific: ascii_field(&inquiry, 36, 43),
firmware_date,
serial_number,
raw_inquiry: inquiry.to_vec(),
})
}
/// Build identity from raw INQUIRY bytes and firmware date string.
/// Used by tests and when serial isn't available.
pub fn from_inquiry(inquiry: &[u8], firmware_date: &str) -> Self {
DriveId {
vendor_id: ascii_field(inquiry, 8, 16),
@@ -74,6 +96,7 @@ impl DriveId {
product_revision: ascii_field(inquiry, 32, 36),
vendor_specific: ascii_field(inquiry, 36, 43),
firmware_date: firmware_date.to_string(),
serial_number: String::new(),
raw_inquiry: inquiry.to_vec(),
}
}
+342
View File
@@ -0,0 +1,342 @@
//! 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());
}
}
+159
View File
@@ -0,0 +1,159 @@
//! Criterion Collection — `streamproperties.xml` + `playbackconfig.xml`
//!
//! Clean structured XML with Content/Qualifier per stream and
//! stream number mapping via playbackconfig.
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
use std::collections::HashMap;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "streamproperties.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let sp_data = super::read_jar_file(session, udf, "streamproperties.xml")?;
let sp_text = std::str::from_utf8(&sp_data).ok()?;
let stream_infos = parse_stream_infos(sp_text);
if stream_infos.is_empty() { return None; }
// Stream number mapping from playbackconfig.xml
let mut stream_map: HashMap<String, u16> = HashMap::new();
if let Some(pc_data) = super::read_jar_file(session, udf, "playbackconfig.xml") {
if let Ok(pc_text) = std::str::from_utf8(&pc_data) {
parse_playback_config(pc_text, &mut stream_map);
}
}
let mut labels = Vec::new();
let mut audio_idx: u16 = 1;
let mut sub_idx: u16 = 1;
for info in &stream_infos {
let stream_num = stream_map.get(&info.id).copied().unwrap_or_else(|| {
match info.stream_type {
StreamLabelType::Audio => { let n = audio_idx; audio_idx += 1; n }
StreamLabelType::Subtitle => { let n = sub_idx; sub_idx += 1; n }
}
});
labels.push(StreamLabel {
stream_number: stream_num,
stream_type: info.stream_type,
language: info.language.clone(),
name: String::new(),
purpose: info.purpose,
qualifier: info.qualifier,
codec_hint: String::new(),
variant: info.variant.clone(),
});
}
if labels.is_empty() { return None; }
Some(labels)
}
struct StreamInfo {
id: String,
stream_type: StreamLabelType,
language: String,
variant: String,
purpose: LabelPurpose,
qualifier: LabelQualifier,
}
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
let mut infos = Vec::new();
let mut pos = 0;
while pos < xml.len() {
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
(p + pos, StreamLabelType::Audio)
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
(p + pos, StreamLabelType::Subtitle)
} else {
break;
};
let end_tag = match stream_type {
StreamLabelType::Audio => "</AudioStreamInfos>",
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
};
let block_end = match xml[tag..].find(end_tag) {
Some(p) => tag + p + end_tag.len(),
None => break,
};
let block = &xml[tag..block_end];
let id = extract_tag(block, "ID").unwrap_or_default();
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
let content = extract_tag(block, "Content").unwrap_or_default();
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
let (language, variant) = if lang_id.contains('_') {
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
(parts[0].to_lowercase(), parts[1].to_string())
} else {
(lang_id.to_lowercase(), String::new())
};
let purpose = match content.as_str() {
"COMMENTARY" => LabelPurpose::Commentary,
_ => LabelPurpose::Normal,
};
let qualifier = match qualifier_str.as_str() {
"SDH" => LabelQualifier::Sdh,
"DS" => LabelQualifier::DescriptiveService,
_ => LabelQualifier::None,
};
infos.push(StreamInfo { id, stream_type, language, variant, purpose, qualifier });
pos = block_end;
}
infos
}
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
let mut pos = 0;
while pos < xml.len() {
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
Some(p + pos)
} else if let Some(p) = xml[pos..].find("<SubtitlesStreams>") {
Some(p + pos)
} else {
None
};
let tag_start = match tag_start {
Some(p) => p,
None => break,
};
let block_end = xml[tag_start..].find("</AudioStreams>")
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
.map(|p| tag_start + p + 20)
.unwrap_or(xml.len());
let block = &xml[tag_start..block_end];
if let (Some(stream_id_str), Some(info_id)) = (extract_tag(block, "StreamID"), extract_tag(block, "StreamInfo_ID")) {
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
map.insert(info_id, stream_num);
}
}
pos = block_end;
}
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
}
+208
View File
@@ -0,0 +1,208 @@
//! Warner CTRM — `menu_base.prop` and/or `language_streams.txt`
//!
//! Two sub-formats from the same framework. A disc may have one or both.
//! When both exist, language_streams.txt provides structured types while
//! menu_base.prop provides stream number → button name mapping.
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
use std::collections::HashMap;
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "menu_base.prop")
|| super::jar_file_exists(udf, "language_streams.txt")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
// Try language_streams.txt first (richer structured data)
let ls_labels = parse_language_streams(session, udf);
// Try menu_base.prop (stream numbers + key names)
let mb_labels = parse_menu_base(session, udf);
// If we have both, merge: language_streams for structure, menu_base for names
match (ls_labels, mb_labels) {
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
(Some(ls), None) => Some(ls),
(None, Some(mb)) => Some(mb),
(None, None) => None,
}
}
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
// language_streams has better type/purpose data, menu_base has button names
// Match by stream number + type, take name from menu_base
let mut result = ls;
for label in &mut result {
if let Some(mb_match) = mb.iter().find(|m|
m.stream_type == label.stream_type && m.stream_number == label.stream_number
) {
if label.name.is_empty() && !mb_match.name.is_empty() {
label.name = mb_match.name.clone();
}
}
}
result
}
// ── language_streams.txt parser ────────────────────────────────────────────
fn parse_language_streams(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "language_streams.txt")?;
let text = std::str::from_utf8(&data).ok()?;
let mut labels = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
if parts.len() < 4 { continue; }
let type_str = parts[1];
let stream_num: u16 = match parts[2].parse() {
Ok(n) => n,
Err(_) => continue,
};
let language = parts[3].to_string();
let variant = if parts.len() > 4 { parts[4].to_string() } else { String::new() };
let (stream_type, purpose, qualifier) = match type_str {
"audio_production" => (StreamLabelType::Audio, LabelPurpose::Normal, LabelQualifier::None),
"audio_commentary" => (StreamLabelType::Audio, LabelPurpose::Commentary, LabelQualifier::None),
"audio_ime" => (StreamLabelType::Audio, LabelPurpose::Ime, LabelQualifier::None),
"subtitle_production" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_commentary" => (StreamLabelType::Subtitle, LabelPurpose::Commentary, LabelQualifier::None),
"subtitle_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::Forced),
"subtitle_dual" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_bonus" => (StreamLabelType::Subtitle, LabelPurpose::Normal, LabelQualifier::None),
"subtitle_ime" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::None),
"subtitle_ime_narrative" => (StreamLabelType::Subtitle, LabelPurpose::Ime, LabelQualifier::Forced),
_ => continue,
};
// Classify variant code
let mut codec_hint = String::new();
let mut variant_code = String::new();
let mut final_purpose = purpose;
if !variant.is_empty() {
match variant.as_str() {
// Codec variants — use shared label vocab
"atmos" | "MLP" | "AC3" | "DTS" | "DDL" => {
codec_hint = vocab::codec(&variant).to_string();
}
// Purpose variants
"eda" => final_purpose = LabelPurpose::Descriptive,
// Dialect variants — pass through raw code from disc
"csp" | "cs" | "lsp" | "ls" | "cf" | "pf" | "bp" | "pp" => {
variant_code = variant.clone();
}
// Unknown — store as-is in codec_hint
_ => codec_hint = variant.clone(),
}
}
labels.push(StreamLabel {
stream_number: stream_num,
stream_type,
language,
name: String::new(),
purpose: final_purpose,
qualifier,
codec_hint,
variant: variant_code,
});
}
if labels.is_empty() { return None; }
Some(labels)
}
// ── menu_base.prop parser ──────────────────────────────────────────────────
fn parse_menu_base(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "menu_base.prop")?;
let text = std::str::from_utf8(&data).ok()?;
// Parse key=value, group by prefix
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') { continue; }
let eq_pos = match line.find('=') {
Some(p) => p,
None => continue,
};
let full_key = &line[..eq_pos];
let value = &line[eq_pos + 1..];
if let Some(dot_pos) = full_key.rfind('.') {
let prefix = full_key[..dot_pos].to_string();
let key = full_key[dot_pos + 1..].to_string();
entries.entry(prefix).or_default().insert(key, value.to_string());
}
}
let mut labels = Vec::new();
for (prefix, props) in &entries {
// Audio: has "streamNumber" or "audioStream" and audio-related class
let is_audio = props.get("class").map_or(false, |c| c.contains("AudioButton"))
|| prefix.starts_with("audio_");
let is_subtitle = props.get("class").map_or(false, |c| c.contains("SubtitleButton"))
|| prefix.starts_with("subtitle_");
let stream_num_str = props.get("streamNumber")
.or_else(|| props.get("audioStream"))
.or_else(|| props.get("subtitleStream"));
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
Some(n) if n > 0 => n,
_ => continue,
};
if !is_audio && !is_subtitle { continue; }
let name = props.get("name").cloned().unwrap_or_default();
let name_lower = name.to_lowercase();
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
let qualifier = if is_subtitle && name_lower.contains("sdh") {
LabelQualifier::Sdh
} else {
LabelQualifier::None
};
let stream_type = if is_audio { StreamLabelType::Audio } else { StreamLabelType::Subtitle };
// Try to extract language from audioLanguage/subtitleLanguage prop
let language = props.get("audioLanguage")
.or_else(|| props.get("subtitleLanguage"))
.cloned()
.unwrap_or_default();
labels.push(StreamLabel {
stream_number: stream_num,
stream_type,
language,
name,
purpose,
qualifier,
codec_hint: String::new(),
variant: String::new(),
});
}
if labels.is_empty() { return None; }
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
Some(labels)
}
+175
View File
@@ -0,0 +1,175 @@
//! Stream label extraction from BD-J disc files.
//!
//! Each parser module represents one BD-J authoring framework.
//! To add a new format:
//! 1. Create `src/labels/myformat.rs`
//! 2. Implement `pub fn detect(udf: &UdfFs) -> bool`
//! 3. Implement `pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>>`
//! 4. Add `mod myformat;` below and one line to `PARSERS` array
mod paramount;
mod criterion;
mod pixelogic;
mod ctrm;
pub mod vocab;
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use crate::disc::{Title, Stream};
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
pub struct StreamLabel {
/// STN index (1-based)
pub stream_number: u16,
/// Audio or Subtitle
pub stream_type: StreamLabelType,
/// ISO 639-2 language code
pub language: String,
/// Display name (e.g. "Commentary", "Descriptive Audio")
pub name: String,
/// Stream purpose
pub purpose: LabelPurpose,
/// Additional qualifier
pub qualifier: LabelQualifier,
/// Codec hint from config (e.g. "TrueHD", "Dolby Digital", "Dolby Atmos")
pub codec_hint: String,
/// Regional variant (e.g. "US", "UK", "Castilian", "Canadian")
pub variant: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StreamLabelType {
Audio,
Subtitle,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelPurpose {
Normal,
Commentary,
Descriptive,
Score,
Ime,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LabelQualifier {
None,
Sdh,
DescriptiveService,
Forced,
}
// ── Parser registry ────────────────────────────────────────────────────────
//
// Each entry: (name, detect_fn, parse_fn)
// Order = priority. First match wins. Highest quality output first.
type DetectFn = fn(&UdfFs) -> bool;
type ParseFn = fn(&mut DriveSession, &UdfFs) -> Option<Vec<StreamLabel>>;
const PARSERS: &[(&str, DetectFn, ParseFn)] = &[
("paramount", paramount::detect, paramount::parse),
("criterion", criterion::detect, criterion::parse),
("pixelogic", pixelogic::detect, pixelogic::parse),
("ctrm", ctrm::detect, ctrm::parse),
// ("deluxe", deluxe::detect, deluxe::parse), // TODO: bytecode parser
];
/// Search disc for config files, extract labels, apply to streams.
/// This is 100% optional — if anything fails, streams are untouched.
pub fn apply(session: &mut DriveSession, udf: &UdfFs, titles: &mut [Title]) {
let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
extract(session, udf)
})).unwrap_or_default();
if labels.is_empty() { return; }
for title in titles.iter_mut() {
let mut audio_idx: u16 = 0;
let mut sub_idx: u16 = 0;
for stream in &mut title.streams {
match stream {
Stream::Audio(a) => {
audio_idx += 1;
if let Some(label) = labels.iter().find(|l|
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
) {
let mut parts = Vec::new();
match label.purpose {
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
LabelPurpose::Descriptive => parts.push("Descriptive Audio".to_string()),
LabelPurpose::Score => parts.push("Score".to_string()),
LabelPurpose::Ime => parts.push("IME".to_string()),
LabelPurpose::Normal => {}
}
if !label.variant.is_empty() {
parts.push(format!("({})", label.variant));
}
if !label.codec_hint.is_empty() {
parts.push(label.codec_hint.clone());
}
if !parts.is_empty() {
a.label = parts.join(" ");
} else if !label.name.is_empty() {
a.label = label.name.clone();
}
}
}
Stream::Subtitle(s) => {
sub_idx += 1;
if let Some(label) = labels.iter().find(|l|
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
) {
if label.qualifier == LabelQualifier::Forced {
s.forced = true;
}
}
}
_ => {}
}
}
}
}
fn extract(session: &mut DriveSession, udf: &UdfFs) -> Vec<StreamLabel> {
for (_name, detect, parse) in PARSERS {
if detect(udf) {
if let Some(labels) = parse(session, udf) {
return labels;
}
}
}
Vec::new()
}
// ── Shared helpers ─────────────────────────────────────────────────────────
/// Check if a file exists in any BDMV/JAR subdirectory.
pub(crate) fn jar_file_exists(udf: &UdfFs, filename: &str) -> bool {
find_jar_file(udf, filename).is_some()
}
/// Find a file in any BDMV/JAR subdirectory, return its path.
pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option<String> {
let jar_dir = udf.find_dir("/BDMV/JAR")?;
for entry in &jar_dir.entries {
if entry.is_dir {
let path = format!("/BDMV/JAR/{}/{}", entry.name, filename);
// Check if file exists in this subdirectory
for child in &entry.entries {
if !child.is_dir && child.name.eq_ignore_ascii_case(filename) {
return Some(path);
}
}
}
}
None
}
/// Read a file from any BDMV/JAR subdirectory by filename.
pub(crate) fn read_jar_file(session: &mut DriveSession, udf: &UdfFs, filename: &str) -> Option<Vec<u8>> {
let path = find_jar_file(udf, filename)?;
udf.read_file(session, &path).ok().filter(|d| !d.is_empty())
}
+142
View File
@@ -0,0 +1,142 @@
//! Paramount/onQ — `playlists.xml`
//!
//! Richest structured format. Complete language lists with forced flags
//! and commentary indices per playlist, all in XML attributes.
//!
//! ```xml
//! <playlist name="Feature" id="00222"
//! aud="eng,deu,spa,spa,fra"
//! sub="eng,eng,zho,ces,dan"
//! forced_sub="0,0,0,1,0"
//! aud_com1_idx="10"
//! sub_com1_idx="23,24,25" />
//! ```
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier};
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "playlists.xml")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "playlists.xml")?;
let text = std::str::from_utf8(&data).ok()?;
// Find the feature playlist — longest duration or name="Feature"
let feature = find_feature_playlist(text)?;
let mut labels = Vec::new();
// Parse audio streams
if let Some(aud) = extract_attr(&feature, "aud") {
let com_idx = extract_attr(&feature, "aud_com1_idx")
.and_then(|s| s.parse::<usize>().ok());
for (i, lang) in aud.split(',').enumerate() {
let lang = lang.trim();
if lang.is_empty() { continue; }
let purpose = if com_idx == Some(i) {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
labels.push(StreamLabel {
stream_number: (i + 1) as u16,
stream_type: StreamLabelType::Audio,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier: LabelQualifier::None,
codec_hint: String::new(),
variant: String::new(),
});
}
}
// Parse subtitle streams
if let Some(sub) = extract_attr(&feature, "sub") {
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
.unwrap_or_default();
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
.unwrap_or_default();
for (i, lang) in sub.split(',').enumerate() {
let lang = lang.trim();
if lang.is_empty() { continue; }
let purpose = if com_indices.contains(&i) {
LabelPurpose::Commentary
} else {
LabelPurpose::Normal
};
let qualifier = if forced.get(i).copied().unwrap_or(false) {
LabelQualifier::Forced
} else {
LabelQualifier::None
};
labels.push(StreamLabel {
stream_number: (i + 1) as u16,
stream_type: StreamLabelType::Subtitle,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: String::new(),
variant: String::new(),
});
}
}
if labels.is_empty() { return None; }
Some(labels)
}
/// Find the feature playlist element (the one with the most audio tracks).
fn find_feature_playlist(xml: &str) -> Option<String> {
let mut best: Option<String> = None;
let mut best_aud_count = 0;
let mut pos = 0;
while let Some(start) = xml[pos..].find("<playlist ") {
let abs_start = pos + start;
let end = match xml[abs_start..].find("/>") {
Some(p) => abs_start + p + 2,
None => break,
};
let element = &xml[abs_start..end];
// Prefer name="Feature" explicitly
if let Some(name) = extract_attr(element, "name") {
if name.eq_ignore_ascii_case("Feature") {
return Some(element.to_string());
}
}
// Otherwise pick the one with the most audio streams
if let Some(aud) = extract_attr(element, "aud") {
let count = aud.split(',').count();
if count > best_aud_count {
best_aud_count = count;
best = Some(element.to_string());
}
}
pos = end;
}
best
}
/// Extract an XML attribute value from an element string.
fn extract_attr<'a>(element: &'a str, name: &str) -> Option<String> {
let needle = format!("{}=\"", name);
let start = element.find(&needle)? + needle.len();
let end = element[start..].find('"')? + start;
Some(element[start..end].to_string())
}
+134
View File
@@ -0,0 +1,134 @@
//! Pixelogic — `bluray_project.bin`
//!
//! Binary file with embedded UTF-8 token strings in STN order per
//! playlist section. Most common format (5/10 test discs).
//!
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use crate::drive::DriveSession;
use crate::udf::UdfFs;
use super::{StreamLabel, StreamLabelType, LabelPurpose, LabelQualifier, vocab};
/// Known audio codec tokens
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
/// Known region tokens
const REGIONS: &[&str] = &["US", "UK", "CF", "PF", "CS", "LS", "BP", "PP", "SM", "TM", "CAN", "DUM", "FLE"];
pub fn detect(udf: &UdfFs) -> bool {
super::jar_file_exists(udf, "bluray_project.bin")
}
pub fn parse(session: &mut DriveSession, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
let data = super::read_jar_file(session, udf, "bluray_project.bin")?;
let strings = extract_strings(&data);
let mut labels = Vec::new();
let mut in_feature = false;
let mut audio_num: u16 = 0;
let mut sub_num: u16 = 0;
for s in &strings {
// Detect feature section start
if s.starts_with("FPL_") || s.starts_with("SEG_MainFeature") {
if in_feature { break; }
in_feature = true;
audio_num = 0;
sub_num = 0;
continue;
}
// Detect section end
if in_feature && (s.starts_with("SEG_") || s.starts_with("SF_") || s.starts_with("FPL_")) {
break;
}
if !in_feature { continue; }
if let Some(label) = parse_token(s) {
match label.stream_type {
StreamLabelType::Audio => {
audio_num += 1;
labels.push(StreamLabel { stream_number: audio_num, ..label });
}
StreamLabelType::Subtitle => {
sub_num += 1;
labels.push(StreamLabel { stream_number: sub_num, ..label });
}
}
}
}
if labels.is_empty() { return None; }
Some(labels)
}
fn parse_token(s: &str) -> Option<StreamLabel> {
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
let parts: Vec<&str> = clean.split('_').collect();
if parts.len() < 2 { return None; }
let lang = parts[0];
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
let mut codec = String::new();
let mut purpose = LabelPurpose::Normal;
let mut qualifier = LabelQualifier::None;
let mut variant = String::new();
let mut is_subtitle = false;
let mut is_audio = false;
for &part in &parts[1..] {
if part.is_empty() { continue; }
if AUDIO_CODECS.contains(&part) { codec = vocab::codec(part).to_string(); is_audio = true; }
else if part == "ADES" { purpose = LabelPurpose::Descriptive; is_audio = true; }
else if part == "ACOM" { purpose = LabelPurpose::Commentary; is_audio = true; }
else if part == "ADLG" { is_audio = true; }
else if part == "ATRI" { is_audio = true; }
else if part == "SDH" { qualifier = LabelQualifier::Sdh; is_subtitle = true; }
else if part == "SDLG" { is_subtitle = true; }
else if part == "SCOM" { purpose = LabelPurpose::Commentary; is_subtitle = true; }
else if part == "STRI" { is_subtitle = true; }
else if part == "TXT" { is_subtitle = true; }
else if part == "FOR" { qualifier = LabelQualifier::Forced; }
else if REGIONS.contains(&part) { variant = part.to_string(); }
else if part.starts_with("PGStream") { is_subtitle = true; }
else { return None; }
}
if !is_audio && !is_subtitle { return None; }
let stream_type = if is_subtitle { StreamLabelType::Subtitle } else { StreamLabelType::Audio };
Some(StreamLabel {
stream_number: 0,
stream_type,
language: lang.to_string(),
name: String::new(),
purpose,
qualifier,
codec_hint: codec,
variant,
})
}
fn extract_strings(data: &[u8]) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
for &b in data {
if b >= 0x20 && b < 0x7f {
current.push(b as char);
} else {
if current.len() > 3 {
strings.push(current.clone());
}
current.clear();
}
}
if current.len() > 3 {
strings.push(current);
}
strings
}
+27
View File
@@ -0,0 +1,27 @@
//! Shared label vocabulary — values we are 100% confident about.
//!
//! Labels come from BD-J authoring tool files (bluray_project.bin,
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
//!
//! Rules:
//! - Only map values we are 100% certain about (published codec names).
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
//! - The app/CLI handles display text, not the lib.
/// Map a codec identifier found in label data to its display name.
///
/// These are well-known codec identifiers used across multiple BD-J
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
/// separately in mpls.rs.
pub fn codec(code: &str) -> &str {
match code {
"MLP" => "TrueHD",
"AC3" | "AC" => "Dolby Digital",
"DTS" => "DTS",
"DDL" => "Dolby Digital Plus",
"WAV" => "PCM",
"atmos" => "Dolby Atmos",
_ => code,
}
}
+62 -26
View File
@@ -1,38 +1,70 @@
//! libfreemkv — Open source raw disc access for optical drives.
//! libfreemkv — Open source optical drive library for 4K UHD / Blu-ray / DVD.
//!
//! Provides SCSI/MMC commands to enable raw reading mode on compatible
//! Blu-ray drives, allowing direct sector access for disc archival
//! and backup purposes.
//!
//! # Architecture
//!
//! The library is data-driven. Drive-specific SCSI command sequences
//! are stored in profile files, not in code. Adding support for a new
//! drive requires only a profile contribution — no rebuild needed.
//!
//! ```text
//! DriveSession (high-level API)
//! ├── Platform trait (per-chipset unlock logic)
//! ├── DriveProfile (per-drive data from JSON profiles)
//! └── ScsiTransport (SG_IO on Linux, IOKit on macOS)
//! ```
//! Handles drive access, disc structure parsing, AACS decryption, and raw
//! sector reading. 206 bundled drive profiles. No external files needed.
//!
//! # Quick Start
//!
//! ```no_run
//! use libfreemkv::DriveSession;
//! use libfreemkv::{DriveSession, Disc, ScanOptions};
//! use std::path::Path;
//!
//! let mut session = DriveSession::open(
//! Path::new("/dev/sr0"),
//! ).unwrap();
//! let mut session = DriveSession::open(Path::new("/dev/sr0")).unwrap();
//! let disc = Disc::scan(&mut session, &ScanOptions::default()).unwrap();
//!
//! session.unlock().unwrap();
//! session.calibrate().unwrap();
//! for title in &disc.titles {
//! println!("{} — {} streams", title.duration_display(), title.streams.len());
//! }
//!
//! let mut buf = vec![0u8; 2048];
//! let n = session.read_sectors(0, 1, &mut buf).unwrap();
//! // Read content (decrypted automatically if AACS keys available)
//! let mut reader = disc.open_title(&mut session, 0).unwrap();
//! while let Some(unit) = reader.read_unit().unwrap() {
//! // 6144 bytes of decrypted content per unit
//! }
//! ```
//!
//! # 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
//! └── Platform
//! └── 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
//! ```
//!
//! # AACS Encryption
//!
//! Disc scanning automatically detects and handles AACS encryption.
//! If a KEYDB.cfg is available (via `ScanOptions` or standard paths),
//! the library resolves keys and decrypts content transparently.
//!
//! Supports AACS 1.0 (Blu-ray) and AACS 2.0 (UHD, with fallback).
//!
//! # Error Codes
//!
//! All errors are structured with numeric codes. No user-facing English
//! text — applications format their own messages.
//!
//! | Range | Category |
//! |-------|----------|
//! | E1xxx | Device errors (not found, permission) |
//! | E2xxx | Profile errors (unsupported drive) |
//! | E3xxx | Unlock errors (failed, signature) |
//! | E4xxx | SCSI errors (command failed, timeout) |
//! | E5xxx | I/O errors |
//! | E6xxx | Disc format errors |
//! | E7xxx | AACS errors |
pub mod error;
pub mod scsi;
@@ -45,6 +77,8 @@ pub mod udf;
pub mod mpls;
pub mod clpi;
pub mod disc;
pub mod aacs;
pub mod labels;
pub use error::{Error, Result};
pub use drive::DriveSession;
@@ -53,4 +87,6 @@ pub use profile::{DriveProfile, Chipset};
pub use platform::{Platform, DriveStatus};
pub use scsi::ScsiTransport;
pub use speed::DriveSpeed;
pub use disc::{Disc, Title, Stream, StreamKind, Codec, HdrFormat, ColorSpace, Extent};
pub use disc::{Disc, DiscFormat, Title, Clip, Stream, VideoStream, AudioStream, SubtitleStream,
Codec, HdrFormat, ColorSpace,
Extent, ContentReader, AacsState, KeySource, ScanOptions};
+203 -44
View File
@@ -15,10 +15,8 @@ pub struct Playlist {
pub version: String,
/// Play items in playback order
pub play_items: Vec<PlayItem>,
/// Number of video streams
pub video_stream_count: u16,
/// Number of audio streams
pub audio_stream_count: u16,
/// Streams from the first play item's STN table
pub streams: Vec<StreamEntry>,
}
/// A play item — one clip reference with in/out times.
@@ -34,73 +32,155 @@ pub struct PlayItem {
pub connection_condition: u8,
}
/// A stream entry from the STN table.
#[derive(Debug, Clone)]
pub struct StreamEntry {
/// Stream category: 1=video, 2=audio, 3=PG subtitle, 4=IG, 5=secondary audio, 6=secondary video, 7=DV EL
pub stream_type: u8,
/// MPEG-TS PID
pub pid: u16,
/// Coding type (0x24=HEVC, 0x1B=H264, 0x83=TrueHD, etc.)
pub coding_type: u8,
/// Video format (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p)
pub video_format: u8,
/// Video frame rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94)
pub video_rate: u8,
/// Audio channel layout (1=mono, 3=stereo, 6=5.1, 12=7.1)
pub audio_format: u8,
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz)
pub audio_rate: u8,
/// ISO 639-2 language code (e.g. "eng")
pub language: String,
/// HDR dynamic range (0=SDR, 1=HDR10, 2=Dolby Vision)
pub dynamic_range: u8,
/// Color space (0=unknown, 1=BT.709, 2=BT.2020)
pub color_space: u8,
/// Whether this is a secondary stream (commentary, PiP, DV EL)
pub secondary: bool,
}
/// Parse an MPLS file from raw bytes.
pub fn parse(data: &[u8]) -> Result<Playlist> {
if data.len() < 40 {
return Err(Error::DiscError { detail: "MPLS too short".into() });
}
// Header: "MPLS" + version (4 bytes ASCII)
if &data[0..4] != b"MPLS" {
return Err(Error::DiscError { detail: "not an MPLS file".into() });
}
let version = String::from_utf8_lossy(&data[4..8]).to_string();
// Offsets table at bytes 8-19
let 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;
let _playlist_mark_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
if playlist_start + 10 > data.len() {
return Err(Error::DiscError { detail: "MPLS playlist offset out of range".into() });
}
// PlayList section
let pl = &data[playlist_start..];
let _pl_length = u32::from_be_bytes([pl[0], pl[1], pl[2], pl[3]]) as usize;
// pl[4..6] reserved
let num_play_items = u16::from_be_bytes([pl[6], pl[7]]) as usize;
let _num_sub_paths = u16::from_be_bytes([pl[8], pl[9]]) as usize;
let mut play_items = Vec::with_capacity(num_play_items);
let mut pos = 10; // start of first play item
let mut video_streams: u16 = 0;
let mut audio_streams: u16 = 0;
for _ in 0..num_play_items {
if playlist_start + pos + 2 > data.len() {
break;
}
let mut streams = Vec::new();
let mut pos = 10;
for item_idx in 0..num_play_items {
if pos + 2 > pl.len() { break; }
let item_length = u16::from_be_bytes([pl[pos], pl[pos + 1]]) as usize;
if playlist_start + pos + item_length + 2 > data.len() {
break;
}
if pos + 2 + item_length > pl.len() { break; }
let item = &pl[pos + 2..pos + 2 + item_length];
if item.len() < 20 { pos += 2 + item_length; continue; }
// Clip ID: 5 bytes ASCII at offset 0 (e.g. "00001")
let clip_id = String::from_utf8_lossy(&item[0..5]).to_string();
// item[5..9] = codec ID ("M2TS")
// item[9] = connection condition (bits)
let connection_condition = item[9] & 0x0F;
// item[10] = ref to STC_id
// item[12..16] = IN_time
let in_time = u32::from_be_bytes([item[12], item[13], item[14], item[15]]);
// item[16..20] = OUT_time
let out_time = u32::from_be_bytes([item[16], item[17], item[18], item[19]]);
// STN table follows at offset 20 within the play item
if item.len() > 22 {
let stn_length = u16::from_be_bytes([item[20], item[21]]) as usize;
if stn_length > 4 && item.len() > 24 {
// Number of primary video/audio entries
let n_video = item[24] as u16;
let n_audio = item[25] as u16;
if video_streams == 0 {
video_streams = n_video;
audio_streams = n_audio;
}
// Parse STN table from the first play item
// PlayItem layout after out_time:
// [20:28] UO_mask_table (8 bytes)
// [28] misc flags (1 byte)
// [29] still_mode (1 byte)
// [30:32] still_time (2 bytes)
// [32:] STN_table
const STN_OFFSET: usize = 32;
if item_idx == 0 && item.len() > STN_OFFSET + 16 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let n_video = item[STN_OFFSET + 4] as usize;
let n_audio = item[STN_OFFSET + 5] as usize;
let n_pg = item[STN_OFFSET + 6] as usize;
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_dv = item[STN_OFFSET + 11] as usize;
let mut spos = STN_OFFSET + 16;
// Primary video
for _ in 0..n_video {
if let Some((entry, next)) = parse_stream_entry(item, spos, 1) {
streams.push(entry);
spos = next;
} else { break; }
}
// Primary audio
for _ in 0..n_audio {
if let Some((entry, next)) = parse_stream_entry(item, spos, 2) {
streams.push(entry);
spos = next;
} else { break; }
}
// PG subtitles
for _ in 0..n_pg {
if let Some((entry, next)) = parse_stream_entry(item, spos, 3) {
streams.push(entry);
spos = next;
} else { break; }
}
// IG (skip but advance)
for _ in 0..n_ig {
if let Some((_, next)) = parse_stream_entry(item, spos, 4) {
spos = next;
} else { break; }
}
// Secondary audio
for _ in 0..n_sec_audio {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 2) {
entry.stream_type = 5;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes: 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; }
}
// Secondary video (PiP)
for _ in 0..n_sec_video {
if let Some((mut entry, next)) = parse_stream_entry(item, spos, 1) {
entry.stream_type = 6;
entry.secondary = true;
streams.push(entry);
// Skip extra ref bytes (audio refs + PG refs)
if next + 2 < item.len() {
let n_arefs = item[next] as usize;
let after_arefs = next + 2 + n_arefs + (n_arefs % 2);
if after_arefs < item.len() {
let n_prefs = item[after_arefs] as usize;
spos = after_arefs + 2 + n_prefs + (n_prefs % 2);
} else { spos = after_arefs; }
} 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) {
entry.stream_type = 7;
entry.secondary = true;
streams.push(entry);
spos = next;
} else { break; }
}
}
@@ -117,7 +197,86 @@ pub fn parse(data: &[u8]) -> Result<Playlist> {
Ok(Playlist {
version,
play_items,
video_stream_count: video_streams,
audio_stream_count: audio_streams,
streams,
})
}
/// Parse one stream entry from the STN table.
/// Returns (StreamEntry, next position) or None.
fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(StreamEntry, usize)> {
if pos + 2 > item.len() { return None; }
// Stream entry: length(1) + data
let se_len = item[pos] as usize;
let se_end = pos + 1 + se_len;
if se_end > item.len() { return None; }
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
} else {
0
};
// Stream attributes: length(1) + coding_type(1) + format-specific data
if se_end + 2 > item.len() { return None; }
let sa_len = item[se_end] as usize;
let sa_end = se_end + 1 + sa_len;
if sa_end > item.len() || sa_len < 1 { return None; }
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;
let mut audio_rate = 0u8;
let mut dynamic_range = 0u8;
let mut color_space_val = 0u8;
let mut language = String::new();
match stream_type {
1 => {
// Video: coding_type(1) + format_rate(1) + [hdr_info(1) if HEVC]
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;
}
}
2 => {
// Audio: coding_type(1) + format_rate(1) + language(3)
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();
}
}
_ => {}
}
Some((StreamEntry {
stream_type,
pid,
coding_type,
video_format,
video_rate,
audio_format,
audio_rate,
language,
dynamic_range,
color_space: color_space_val,
secondary: false,
}, sa_end))
}
-248
View File
@@ -1,248 +0,0 @@
//! SCSI/MMC command interface.
//!
//! Platform backends:
//! - Linux: SG_IO ioctl
//! - macOS: IOKit SCSI passthrough (planned)
//! - Windows: SPTI (planned)
use crate::error::{Error, Result};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DataDirection {
None,
FromDevice,
ToDevice,
}
#[derive(Debug)]
pub struct ScsiResult {
pub status: u8,
pub bytes_transferred: usize,
pub sense: [u8; 32],
}
/// Low-level SCSI transport — implemented per platform.
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
// ─── Linux: SG_IO ───────────────────────────────────────────────────────────
#[cfg(target_os = "linux")]
const SG_IO: u32 = 0x2285;
#[cfg(target_os = "linux")]
const SG_DXFER_NONE: i32 = -1;
#[cfg(target_os = "linux")]
const SG_DXFER_TO_DEV: i32 = -2;
#[cfg(target_os = "linux")]
const SG_DXFER_FROM_DEV: i32 = -3;
#[cfg(target_os = "linux")]
#[repr(C)]
#[allow(non_camel_case_types)]
struct sg_io_hdr {
interface_id: i32,
dxfer_direction: i32,
cmd_len: u8,
mx_sb_len: u8,
iovec_count: u16,
dxfer_len: u32,
dxferp: *mut u8,
cmdp: *const u8,
sbp: *mut u8,
timeout: u32,
flags: u32,
pack_id: i32,
usr_ptr: *mut libc::c_void,
status: u8,
masked_status: u8,
msg_status: u8,
sb_len_wr: u8,
host_status: u16,
driver_status: u16,
resid: i32,
duration: u32,
info: u32,
}
#[cfg(target_os = "linux")]
pub struct SgIoTransport {
fd: i32,
}
#[cfg(target_os = "linux")]
impl SgIoTransport {
pub fn open(device: &Path) -> Result<Self> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
let fd = unsafe { libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK) };
if fd < 0 {
return Err(Error::DeviceNotFound { path: device.display().to_string() });
}
Ok(SgIoTransport { fd })
}
}
#[cfg(target_os = "linux")]
impl Drop for SgIoTransport {
fn drop(&mut self) {
unsafe { libc::close(self.fd); }
}
}
#[cfg(target_os = "linux")]
impl ScsiTransport for SgIoTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
DataDirection::None => SG_DXFER_NONE,
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
DataDirection::ToDevice => SG_DXFER_TO_DEV,
};
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
hdr.interface_id = b'S' as i32;
hdr.dxfer_direction = dxfer_direction;
hdr.cmd_len = cdb.len() as u8;
hdr.mx_sb_len = sense.len() as u8;
hdr.dxfer_len = data.len() as u32;
hdr.dxferp = data.as_mut_ptr();
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
let ret = unsafe {
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
};
if ret < 0 {
return Err(Error::IoError { source: std::io::Error::last_os_error() });
}
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense_key,
});
}
Ok(ScsiResult {
status: hdr.status,
bytes_transferred,
sense,
})
}
}
// ─── macOS: IOKit (planned) ─────────────────────────────────────────────────
// TODO: IOKit MMC SCSI passthrough
// Use IOSCSIPeripheralDeviceType05 (MMC device nub)
// Send SCSITaskInterface commands via IOKit user client
// ─── Windows: SPTI (planned) ────────────────────────────────────────────────
// TODO: SCSI Pass Through Interface
// Use CreateFile on \\.\CdRomN
// Send IOCTL_SCSI_PASS_THROUGH_DIRECT
// ─── Platform-agnostic open ─────────────────────────────────────────────────
/// Open a SCSI transport for the given device path.
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
#[cfg(target_os = "linux")]
{ Ok(Box::new(SgIoTransport::open(device)?)) }
#[cfg(not(target_os = "linux"))]
{ Err(Error::DeviceNotFound { path: format!("{}: platform not yet supported (Linux only)", device.display()) }) }
}
// ─── CDB builders (platform-agnostic) ───────────────────────────────────────
/// SCSI INQUIRY response.
#[derive(Debug, Clone)]
pub struct InquiryResult {
pub vendor_id: String,
pub model: String,
pub firmware: String,
pub raw: Vec<u8>,
}
/// Send INQUIRY command and parse the standard response fields.
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
let cdb = [0x12, 0x00, 0x00, 0x00, 0x60, 0x00];
let mut buf = [0u8; 96];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
let vendor = String::from_utf8_lossy(&buf[8..16]).trim().to_string();
let model = String::from_utf8_lossy(&buf[16..32]).trim().to_string();
let firmware = String::from_utf8_lossy(&buf[32..36]).trim().to_string();
Ok(InquiryResult {
vendor_id: vendor,
model,
firmware,
raw: buf.to_vec(),
})
}
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let cdb = [0x46, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut buf = [0u8; 16];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf.to_vec())
}
/// Build a READ BUFFER (0x3C) CDB.
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
[
0x3C, mode, buffer_id,
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
(length >> 16) as u8, (length >> 8) as u8, length as u8,
0x00,
]
}
/// Build a SET CD SPEED (0xBB) CDB.
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
[
0xBB, 0x00,
(read_speed >> 8) as u8, read_speed as u8,
0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
}
/// Build a READ(10) CDB with the raw read flag (0x08).
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
[
0x28, 0x08,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
]
}
+120
View File
@@ -0,0 +1,120 @@
//! Linux SCSI transport via SG_IO ioctl.
use crate::error::{Error, Result};
use super::{ScsiTransport, ScsiResult, DataDirection};
use std::path::Path;
const SG_IO: u32 = 0x2285;
const SG_DXFER_NONE: i32 = -1;
const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
#[repr(C)]
#[allow(non_camel_case_types)]
struct sg_io_hdr {
interface_id: i32,
dxfer_direction: i32,
cmd_len: u8,
mx_sb_len: u8,
iovec_count: u16,
dxfer_len: u32,
dxferp: *mut u8,
cmdp: *const u8,
sbp: *mut u8,
timeout: u32,
flags: u32,
pack_id: i32,
usr_ptr: *mut libc::c_void,
status: u8,
masked_status: u8,
msg_status: u8,
sb_len_wr: u8,
host_status: u16,
driver_status: u16,
resid: i32,
duration: u32,
info: u32,
}
pub struct SgIoTransport {
fd: i32,
}
impl SgIoTransport {
pub fn open(device: &Path) -> Result<Self> {
use std::os::unix::ffi::OsStrExt;
let path_bytes = device.as_os_str().as_bytes();
let mut c_path = Vec::with_capacity(path_bytes.len() + 1);
c_path.extend_from_slice(path_bytes);
c_path.push(0);
let fd = unsafe {
libc::open(c_path.as_ptr() as *const libc::c_char, libc::O_RDWR | libc::O_NONBLOCK)
};
if fd < 0 {
return Err(Error::DeviceNotFound { path: device.display().to_string() });
}
Ok(SgIoTransport { fd })
}
}
impl Drop for SgIoTransport {
fn drop(&mut self) {
unsafe { libc::close(self.fd); }
}
}
impl ScsiTransport for SgIoTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
DataDirection::None => SG_DXFER_NONE,
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
DataDirection::ToDevice => SG_DXFER_TO_DEV,
};
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
hdr.interface_id = b'S' as i32;
hdr.dxfer_direction = dxfer_direction;
hdr.cmd_len = cdb.len() as u8;
hdr.mx_sb_len = sense.len() as u8;
hdr.dxfer_len = data.len() as u32;
hdr.dxferp = data.as_mut_ptr();
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
let ret = unsafe {
libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr)
};
if ret < 0 {
return Err(Error::IoError { source: std::io::Error::last_os_error() });
}
let bytes_transferred = (data.len() as i32 - hdr.resid) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: hdr.status,
sense_key,
});
}
Ok(ScsiResult {
status: hdr.status,
bytes_transferred,
sense,
})
}
}
+465
View File
@@ -0,0 +1,465 @@
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
//!
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
//!
//! Requires exclusive access to the device — unmount the disc first:
//! `diskutil unmountDisk /dev/disk2`
use crate::error::{Error, Result};
use super::{DataDirection, ScsiResult, ScsiTransport};
use std::path::Path;
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
type CFMutableDictionaryRef = *mut std::ffi::c_void;
type IOObject = u32;
type IOReturn = i32;
type MachPort = u32;
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
/// to the function table.
type ComRef = *mut *mut std::ffi::c_void;
const K_IO_RETURN_SUCCESS: IOReturn = 0;
// SCSI data transfer directions (SCSITaskLib.h)
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
// SCSI task status values
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
const K_MAX_CDB_SIZE: usize = 16;
const K_SENSE_DATA_SIZE: usize = 32;
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
// From IOKit/scsi/SCSITaskLib.h
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6,
0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
];
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4,
0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
];
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6,
0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
// ── Scatter/gather element ──────────────────────────────────────────────────
#[repr(C)]
struct SCSITaskSGElement {
address: u64,
length: u64,
}
// ── External IOKit / CoreFoundation functions ───────────────────────────────
extern "C" {
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
fn IOBSDNameMatching(
master: MachPort,
options: u32,
bsd_name: *const u8,
) -> CFMutableDictionaryRef;
fn IOServiceGetMatchingService(
master: MachPort,
matching: CFMutableDictionaryRef,
) -> IOObject;
fn IOObjectRelease(object: IOObject) -> IOReturn;
fn IORegistryEntryGetParentEntry(
entry: IOObject,
plane: *const u8,
parent: *mut IOObject,
) -> IOReturn;
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
fn IOCreatePlugInInterfaceForService(
service: IOObject,
plugin_type: *const [u8; 16],
interface_type: *const [u8; 16],
the_interface: *mut ComRef,
the_score: *mut i32,
) -> IOReturn;
}
// ── COM vtable helpers ──────────────────────────────────────────────────────
//
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
// dereferencing once gives the vtable pointer, then index into it for
// individual function pointers.
//
// All vtable indices verified against Apple open source:
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
/// Read a function pointer from a COM vtable at the given index.
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
let vtable = *iface as *const *const std::ffi::c_void;
let fn_ptr = *vtable.add(index);
std::mem::transmute_copy(&fn_ptr)
}
/// Call Release (vtable index 3) on any COM interface.
fn com_release(iface: ComRef) {
type Fn = unsafe extern "C" fn(ComRef) -> u32;
unsafe {
let f: Fn = vtable_fn(iface, 3);
f(iface);
}
}
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
//
// Index Method
// 0 _reserved
// 1 QueryInterface
// 2 AddRef
// 3 Release
// 4 IsExclusiveAccessAvailable
// 5 AddCallbackDispatcherToRunLoop
// 6 RemoveCallbackDispatcherFromRunLoop
// 7 ObtainExclusiveAccess
// 8 ReleaseExclusiveAccess
// 9 CreateSCSITask
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
const VTIDX_CREATE_TASK: usize = 9;
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
//
// Index Method
// 0 _reserved
// 1 QueryInterface
// 2 AddRef
// 3 Release
// 4 IsTaskActive
// 5 SetTaskAttribute
// 6 GetTaskAttribute
// 7 GetTaskState
// 8 SetCommandDescriptorBlock
// 9 GetCommandDescriptorBlockSize
// 10 GetCommandDescriptorBlock
// 11 SetScatterGatherEntries
// 12 SetTimeoutDuration
// 13 GetTimeoutDuration
// 14 SetTaskCompletionCallback
// 15 ExecuteTaskSync
// 16 ExecuteTaskAsync
// 17 AbortTask
// 18 GetSCSIServiceResponse
// 19 GetTaskStatus
// 20 GetRealizedDataTransferCount
// 21 GetAutoSenseData
const VTIDX_SET_CDB: usize = 8;
const VTIDX_SET_SG: usize = 11;
const VTIDX_SET_TIMEOUT: usize = 12;
const VTIDX_EXECUTE_SYNC: usize = 15;
// ── Transport implementation ────────────────────────────────────────────────
pub struct MacScsiTransport {
device_iface: ComRef,
exclusive: bool,
}
impl MacScsiTransport {
pub fn open(device: &Path) -> Result<Self> {
let dev_str = device.to_str().ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
rest
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
rest
} else {
dev_str
};
let service = find_scsi_service(bsd_name)?;
// Create IOKit plugin for the MMC device
let mut plugin: ComRef = std::ptr::null_mut();
let mut score: i32 = 0;
let kr = unsafe {
IOCreatePlugInInterfaceForService(
service,
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
&K_IO_CFPLUGIN_INTERFACE_ID,
&mut plugin,
&mut score,
)
};
unsafe { IOObjectRelease(service) };
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
});
}
// QueryInterface for SCSITaskDeviceInterface
let mut device_iface: ComRef = std::ptr::null_mut();
let hr = unsafe {
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
let qi: QiFn = vtable_fn(plugin, 1);
qi(plugin, &K_IO_SCSI_TASK_DEVICE_INTERFACE_ID, &mut device_iface)
};
com_release(plugin);
if hr != 0 || device_iface.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
});
}
// Obtain exclusive access
let kr = unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
f(device_iface)
};
if kr != K_IO_RETURN_SUCCESS {
com_release(device_iface);
return Err(Error::DevicePermission {
path: format!(
"{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}",
dev_str, kr, dev_str
),
});
}
Ok(MacScsiTransport {
device_iface,
exclusive: true,
})
}
}
impl Drop for MacScsiTransport {
fn drop(&mut self) {
if self.exclusive {
unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
f(self.device_iface);
}
}
com_release(self.device_iface);
}
}
impl ScsiTransport for MacScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
// Create a SCSI task
let task: ComRef = unsafe {
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
f(self.device_iface)
};
if task.is_null() {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
// Set CDB
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
unsafe {
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
f(task, cdb_padded.as_ptr(), cdb_len as u8);
}
// Set scatter/gather and transfer direction
let iokit_dir = match direction {
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
};
if direction != DataDirection::None && !data.is_empty() {
let sg = SCSITaskSGElement {
address: data.as_mut_ptr() as u64,
length: data.len() as u64,
};
unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *const SCSITaskSGElement, u8, u64, u8,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
f(task, &sg, 1, data.len() as u64, iokit_dir);
}
} else {
unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *const SCSITaskSGElement, u8, u64, u8,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
}
}
// Set timeout (IOKit SCSITask takes milliseconds)
unsafe {
type Fn = unsafe extern "C" fn(ComRef, u32);
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
f(task, timeout_ms);
}
// Execute synchronously
let mut sense = [0u8; K_SENSE_DATA_SIZE];
let mut task_status: u8 = 0;
let mut realized_count: u64 = 0;
let kr = unsafe {
type Fn = unsafe extern "C" fn(
ComRef, *mut u8, *mut u8, *mut u64,
) -> IOReturn;
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
f(task, sense.as_mut_ptr(), &mut task_status, &mut realized_count)
};
com_release(task);
if kr != K_IO_RETURN_SUCCESS {
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
if task_status != K_SCSI_TASK_STATUS_GOOD {
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: task_status,
sense_key,
});
}
Ok(ScsiResult {
status: task_status,
bytes_transferred: realized_count as usize,
sense,
})
}
}
// ── IOKit service discovery ─────────────────────────────────────────────────
/// BSD name → IOKit service for the SCSI device.
///
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
let mut master: MachPort = 0;
let kr = unsafe { IOMasterPort(0, &mut master) };
if kr != K_IO_RETURN_SUCCESS {
return Err(Error::DeviceNotFound {
path: format!("{}: IOMasterPort failed", bsd_name),
});
}
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
let mut bsd_c = bsd_name.as_bytes().to_vec();
bsd_c.push(0);
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
if matching.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOBSDNameMatching failed", bsd_name),
});
}
// Find the single IOMedia service (consumes the matching dict)
let media = unsafe { IOServiceGetMatchingService(master, matching) };
if media == 0 {
return Err(Error::DeviceNotFound {
path: format!("{}: no IOMedia found", bsd_name),
});
}
// Walk up the IOService plane to find the authoring device.
// The chain is typically:
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
// → IOSCSIPeripheralDeviceNub (this is what we want)
//
// We walk up until we find a service that IOCreatePlugInInterfaceForService
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
let service = walk_to_authoring_device(media);
unsafe { IOObjectRelease(media) };
service.ok_or_else(|| Error::DeviceNotFound {
path: format!("{}: no SCSI authoring device in IORegistry", bsd_name),
})
}
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
let mut current = start;
// Retain start so we can release uniformly in the loop
// (IORegistryEntryGetParentEntry retains the parent for us)
// Target class names for authoring devices
let target_classes: &[&[u8]] = &[
b"IOSCSIPeripheralDeviceNub\0",
b"IOBDBlockStorageDevice\0",
b"IODVDBlockStorageDevice\0",
b"IOCDBlockStorageDevice\0",
b"IOBlockStorageDevice\0",
];
// Walk up to 10 levels (more than enough)
for _ in 0..10 {
let mut parent: IOObject = 0;
let kr = unsafe {
IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent)
};
if current != start {
unsafe { IOObjectRelease(current) };
}
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
return None;
}
// Check if this parent matches any of our target classes
for class in target_classes {
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
return Some(parent);
}
}
current = parent;
}
if current != start {
unsafe { IOObjectRelease(current) };
}
None
}
+142
View File
@@ -0,0 +1,142 @@
//! SCSI/MMC command interface.
//!
//! Platform backends are in separate files:
//! - `linux.rs` — SG_IO ioctl
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
//! - `windows.rs` — SPTI (planned)
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use std::path::Path;
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
pub const SCSI_INQUIRY: u8 = 0x12;
pub const SCSI_READ_CAPACITY: u8 = 0x25;
pub const SCSI_READ_10: u8 = 0x28;
pub const SCSI_READ_BUFFER: u8 = 0x3C;
pub const SCSI_READ_TOC: u8 = 0x43;
pub const SCSI_GET_CONFIGURATION: u8 = 0x46;
pub const SCSI_SET_CD_SPEED: u8 = 0xBB;
pub const SCSI_SEND_KEY: u8 = 0xA3;
pub const SCSI_REPORT_KEY: u8 = 0xA4;
pub const SCSI_READ_12: u8 = 0xA8;
pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
/// AACS key class for REPORT KEY / SEND KEY commands.
pub const AACS_KEY_CLASS: u8 = 0x02;
// ── Types ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DataDirection {
None,
FromDevice,
ToDevice,
}
#[derive(Debug)]
pub struct ScsiResult {
pub status: u8,
pub bytes_transferred: usize,
pub sense: [u8; 32],
}
/// Low-level SCSI transport — one implementation per platform.
pub trait ScsiTransport {
fn execute(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
}
// ── Platform-agnostic open ──────────────────────────────────────────────────
/// Open a SCSI transport for the given device path.
/// Selects the right backend for the current platform.
pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
#[cfg(target_os = "linux")]
{ Ok(Box::new(linux::SgIoTransport::open(device)?)) }
#[cfg(target_os = "macos")]
{ Ok(Box::new(macos::MacScsiTransport::open(device)?)) }
#[cfg(target_os = "windows")]
{ Err(Error::DeviceNotFound { path: format!("{}: Windows not yet supported", device.display()) }) }
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{ Err(Error::DeviceNotFound { path: format!("{}: unsupported platform", device.display()) }) }
}
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
/// SCSI INQUIRY response.
#[derive(Debug, Clone)]
pub struct InquiryResult {
pub vendor_id: String,
pub model: String,
pub firmware: String,
pub raw: Vec<u8>,
}
/// Send INQUIRY and parse standard response fields.
pub fn inquiry(scsi: &mut dyn ScsiTransport) -> Result<InquiryResult> {
let cdb = [SCSI_INQUIRY, 0x00, 0x00, 0x00, 0x60, 0x00];
let mut buf = [0u8; 96];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(InquiryResult {
vendor_id: String::from_utf8_lossy(&buf[8..16]).trim().to_string(),
model: String::from_utf8_lossy(&buf[16..32]).trim().to_string(),
firmware: String::from_utf8_lossy(&buf[32..36]).trim().to_string(),
raw: buf.to_vec(),
})
}
/// Send GET CONFIGURATION for feature 0x010C (Firmware Information).
pub fn get_config_010c(scsi: &mut dyn ScsiTransport) -> Result<Vec<u8>> {
let cdb = [SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00];
let mut buf = [0u8; 16];
scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
Ok(buf.to_vec())
}
/// Build a READ BUFFER CDB.
pub fn build_read_buffer(mode: u8, buffer_id: u8, offset: u32, length: u32) -> [u8; 10] {
[
SCSI_READ_BUFFER, mode, buffer_id,
(offset >> 16) as u8, (offset >> 8) as u8, offset as u8,
(length >> 16) as u8, (length >> 8) as u8, length as u8,
0x00,
]
}
/// Build a SET CD SPEED CDB.
pub fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
[
SCSI_SET_CD_SPEED, 0x00,
(read_speed >> 8) as u8, read_speed as u8,
0xFF, 0xFF,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]
}
/// Build a READ(10) CDB with the raw read flag.
pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
[
SCSI_READ_10, 0x08,
(lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, lba as u8,
0x00,
(count >> 8) as u8, count as u8,
0x00,
]
}
+1 -3
View File
@@ -65,9 +65,7 @@ impl DriveSpeed {
22_001..=31_000 => DriveSpeed::BD6x,
31_001..=40_000 => DriveSpeed::BD8x,
40_001..=49_000 => DriveSpeed::BD10x,
49_001..=0xFFFE => DriveSpeed::BD12x,
0xFFFF => DriveSpeed::Max,
_ => DriveSpeed::Max,
49_001..=u16::MAX => DriveSpeed::BD12x,
}
}
+125 -3
View File
@@ -26,11 +26,15 @@ use crate::drive::DriveSession;
pub struct UdfFs {
/// Root directory with full tree
pub root: DirEntry,
/// UDF Volume Identifier from Primary Volume Descriptor
pub volume_id: String,
/// Physical partition start (absolute sector)
partition_start: u32,
/// Metadata partition start (absolute sector)
/// For UDF 2.50 discs, all file/directory references use metadata-relative LBAs
metadata_start: u32,
/// Metadata partition size in sectors
metadata_sectors: u32,
}
/// A directory or file entry.
@@ -47,6 +51,12 @@ pub struct DirEntry {
}
impl UdfFs {
/// Physical partition start sector.
pub fn partition_start(&self) -> u32 { self.partition_start }
/// Metadata partition start sector.
pub fn metadata_start(&self) -> u32 { self.metadata_start }
/// Find a directory by path (e.g. "/BDMV/PLAYLIST").
/// Path matching is case-insensitive.
pub fn find_dir(&self, path: &str) -> Option<&DirEntry> {
@@ -87,9 +97,11 @@ impl UdfFs {
let (data_lba, data_len) = self.read_icb_extent(session, entry.meta_lba)?;
// Read file data sector by sector
// File DATA is in the physical partition (partition_start + lba),
// NOT the metadata partition. ICBs are in metadata, data is in physical.
let sector_count = ((data_len as u64 + 2047) / 2048) as u32;
let mut data = vec![0u8; (sector_count as usize) * 2048];
let abs_start = self.meta_to_abs(data_lba);
let abs_start = self.partition_start + data_lba;
for i in 0..sector_count {
let offset = (i as usize) * 2048;
@@ -100,6 +112,59 @@ impl UdfFs {
Ok(data)
}
/// Collect all sector ranges needed for disc-info and AACS.
///
/// Returns a list of (start_lba, sector_count) ranges covering:
/// - UDF structure (AVDP, VDS, metadata partition, directories)
/// - BDMV/PLAYLIST/*.mpls, CLIPINF/*.clpi, JAR/*, META/*, *.bdmv
/// - AACS/* (Content*.cer, Unit_Key_RO.inf, CPSUnit*.cci)
///
/// Skips: STREAM/ (video), BACKUP/, DUPLICATE/,
/// MKB_RO.inf, ContentHash*, ContentRevocation*
pub fn metadata_sector_ranges(&self, session: &mut DriveSession) -> Result<Vec<(u32, u32)>> {
let mut ranges = Vec::new();
// UDF structure: sector 0 through end of metadata partition
// Covers AVDP, VDS, partition descriptor, metadata ICB, FSD, all directories
let meta_end = self.metadata_start + self.metadata_sectors;
ranges.push((0, meta_end));
// Walk tree, collect ranges for each metadata file
self.collect_file_ranges(session, &self.root, &mut ranges)?;
// Merge overlapping/adjacent ranges and sort
ranges.sort_by_key(|r| r.0);
let merged = merge_ranges(&ranges);
Ok(merged)
}
fn collect_file_ranges(&self, session: &mut DriveSession, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>) -> Result<()> {
for child in &entry.entries {
if child.is_dir {
// Only skip STREAM — those are the multi-GB video files
if child.name.eq_ignore_ascii_case("STREAM") {
continue;
}
self.collect_file_ranges(session, child, ranges)?;
} else {
// Include the ICB sector itself (in metadata partition)
ranges.push((self.meta_to_abs(child.meta_lba), 1));
// Include file data — skip only truly huge files (MKB_RO.inf = 134MB)
if child.size > 50_000_000 {
continue;
}
if let Ok((data_lba, data_len)) = self.read_icb_extent(session, child.meta_lba) {
let abs_start = self.partition_start + data_lba;
let sector_count = (data_len + 2047) / 2048;
ranges.push((abs_start, sector_count));
}
}
}
Ok(())
}
/// Convert a metadata-partition-relative LBA to an absolute sector number.
fn meta_to_abs(&self, meta_lba: u32) -> u32 {
self.metadata_start + meta_lba
@@ -179,6 +244,8 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let mut partition_start: u32 = 0;
let mut num_partition_maps: u32 = 0;
let mut lvd_sector: Option<u32> = None;
let mut volume_id = String::new();
let mut metadata_size_bytes: u32 = 0;
for i in 32..64 {
let mut desc = [0u8; 2048];
@@ -186,6 +253,10 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let desc_tag = u16::from_le_bytes([desc[0], desc[1]]);
match desc_tag {
// Primary Volume Descriptor — volume identifier at offset 24, 32-byte d-string
1 => {
volume_id = parse_dstring(&desc[24..56]);
}
// Partition Descriptor — tells us where the physical partition starts
5 => {
partition_start = u32::from_le_bytes([desc[188], desc[189], desc[190], desc[191]]);
@@ -219,7 +290,7 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Parse partition maps starting at offset 440
// Map 0 = Type 1 (physical), Map 1 = Type 2 (metadata)
let pm1_type = lvd[440]; // First map type
let _pm1_type = lvd[440]; // First map type
let pm1_len = lvd[441] as usize;
if pm1_len > 0 && 440 + pm1_len < 2048 {
@@ -239,8 +310,9 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
let l_ea = u32::from_le_bytes([meta_icb[208], meta_icb[209],
meta_icb[210], meta_icb[211]]) as usize;
let ad_off = 216 + l_ea;
let _ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
let ad_len = u32::from_le_bytes([meta_icb[ad_off], meta_icb[ad_off + 1],
meta_icb[ad_off + 2], meta_icb[ad_off + 3]]) & 0x3FFFFFFF;
metadata_size_bytes = ad_len;
let ad_pos = u32::from_le_bytes([meta_icb[ad_off + 4], meta_icb[ad_off + 5],
meta_icb[ad_off + 6], meta_icb[ad_off + 7]]);
// Metadata content starts at partition_start + ad_pos
@@ -279,10 +351,14 @@ pub fn read_filesystem(session: &mut DriveSession) -> Result<UdfFs> {
// Step 5: Read root directory and build file tree
let root = read_directory(session, partition_start, metadata_start, root_lba, "", 0)?;
let metadata_sectors = (metadata_size_bytes + 2047) / 2048;
Ok(UdfFs {
root,
volume_id,
partition_start,
metadata_start,
metadata_sectors,
})
}
@@ -454,6 +530,52 @@ fn parse_udf_name(data: &[u8]) -> String {
}
}
/// Merge overlapping or adjacent (start, count) ranges.
fn merge_ranges(ranges: &[(u32, u32)]) -> Vec<(u32, u32)> {
if ranges.is_empty() { return Vec::new(); }
let mut result = vec![ranges[0]];
for &(start, count) in &ranges[1..] {
let last = result.last_mut().unwrap();
let last_end = last.0 + last.1;
if start <= last_end + 1 {
// Overlapping or adjacent — extend
let new_end = (start + count).max(last_end);
last.1 = new_end - last.0;
} else {
result.push((start, count));
}
}
result
}
/// Parse a UDF d-string (fixed-length field with length byte at the end).
/// Used for Volume Identifier and other UDF descriptor strings.
/// The first byte of content is a compression ID: 8 = ASCII, 16 = UTF-16BE.
fn parse_dstring(data: &[u8]) -> String {
if data.is_empty() { return String::new(); }
let len = *data.last().unwrap() as usize;
if len == 0 || len > data.len() { return String::new(); }
let content = &data[..len];
if content.is_empty() { return String::new(); }
match content[0] {
8 => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
16 => {
let mut s = String::new();
let chars = &content[1..];
for i in (0..chars.len()).step_by(2) {
if i + 1 < chars.len() {
let c = ((chars[i] as u16) << 8) | chars[i + 1] as u16;
if c != 0 {
if let Some(ch) = char::from_u32(c as u32) { s.push(ch); }
}
}
}
s.trim().to_string()
}
_ => String::from_utf8_lossy(&content[1..]).trim_end_matches('\0').trim().to_string(),
}
}
/// Read a single 2048-byte sector from the drive.
/// Uses standard READ(10) — no unlock required.
fn read_sector(session: &mut DriveSession, lba: u32, buf: &mut [u8]) -> Result<()> {